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

Merge branch 'master' into imprv/get-layout-pattern
- resolve conflict

Taichi Masuyama 3 лет назад
Родитель
Сommit
888f6e1886
95 измененных файлов с 1178 добавлено и 1188 удалено
  1. 0 1
      packages/app/_obsolete/src/client/nologin.jsx
  2. 0 1
      packages/app/package.json
  3. 0 11
      packages/app/public/static/locales/en_US/admin.json
  4. 0 1
      packages/app/public/static/locales/en_US/translation.json
  5. 0 11
      packages/app/public/static/locales/ja_JP/admin.json
  6. 0 11
      packages/app/public/static/locales/zh_CN/admin.json
  7. 0 1
      packages/app/public/static/locales/zh_CN/translation.json
  8. 0 77
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  9. 0 9
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  10. 1 4
      packages/app/src/client/services/AdminMarkDownContainer.js
  11. 2 19
      packages/app/src/client/services/activate-plugin.ts
  12. 12 5
      packages/app/src/client/services/page-operation.ts
  13. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  14. 0 103
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  15. 67 0
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx
  16. 22 10
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  17. 0 47
      packages/app/src/components/Admin/Customize/ThemeColorBox.jsx
  18. 42 0
      packages/app/src/components/Admin/Customize/ThemeColorBox.tsx
  19. 9 8
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  20. 0 42
      packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx
  21. 0 139
      packages/app/src/components/Admin/Security/BasicSecuritySettingContents.jsx
  22. 4 13
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  23. 2 2
      packages/app/src/components/Layout/Admin.module.scss
  24. 0 4
      packages/app/src/components/Layout/NoLoginLayout.module.scss
  25. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  26. 0 1
      packages/app/src/components/LoginForm.tsx
  27. 2 2
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  28. 6 2
      packages/app/src/components/Page.tsx
  29. 1 1
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  30. 14 4
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  31. 28 39
      packages/app/src/components/PageEditor.tsx
  32. 0 112
      packages/app/src/components/PageEditor/Cheatsheet.jsx
  33. 114 0
      packages/app/src/components/PageEditor/Cheatsheet.tsx
  34. 0 8
      packages/app/src/components/PageEditor/Editor.module.scss
  35. 2 2
      packages/app/src/components/PageEditor/Editor.tsx
  36. 3 2
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  37. 38 41
      packages/app/src/components/PageEditorByHackmd.tsx
  38. 5 3
      packages/app/src/components/PageStatusAlert.tsx
  39. 10 5
      packages/app/src/components/SavePageControls.tsx
  40. 4 2
      packages/app/src/components/Sidebar.tsx
  41. 12 0
      packages/app/src/components/Sidebar/CustomSidebar.module.scss
  42. 11 15
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  43. 22 20
      packages/app/src/components/Sidebar/PageTree.tsx
  44. 14 1
      packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss
  45. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  46. 17 0
      packages/app/src/components/Sidebar/RecentChanges.module.scss
  47. 43 67
      packages/app/src/components/Sidebar/RecentChanges.tsx
  48. 14 0
      packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx
  49. 18 0
      packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx
  50. 18 0
      packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx
  51. 40 0
      packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx
  52. 6 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss
  53. 50 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx
  54. 23 0
      packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  55. 10 0
      packages/app/src/components/Sidebar/Tag.module.scss
  56. 5 10
      packages/app/src/components/Sidebar/Tag.tsx
  57. 1 1
      packages/app/src/components/Skeleton.tsx
  58. 0 12
      packages/app/src/interfaces/activity.ts
  59. 4 1
      packages/app/src/interfaces/customize.ts
  60. 8 2
      packages/app/src/interfaces/plugin.ts
  61. 5 0
      packages/app/src/interfaces/rehype.ts
  62. 3 1
      packages/app/src/interfaces/services/renderer.ts
  63. 0 19
      packages/app/src/interfaces/theme.ts
  64. 25 0
      packages/app/src/migrations/20221219011829-remove-basic-auth-related-config.js
  65. 4 2
      packages/app/src/pages/[[...path]].page.tsx
  66. 56 54
      packages/app/src/pages/_document.page.tsx
  67. 1 1
      packages/app/src/pages/admin/customize.page.tsx
  68. 1 1
      packages/app/src/pages/admin/plugins.page.tsx
  69. 0 4
      packages/app/src/pages/admin/security.page.tsx
  70. 0 1
      packages/app/src/pages/login.page.tsx
  71. 0 1
      packages/app/src/server/crowi/index.js
  72. 2 8
      packages/app/src/server/models/config.ts
  73. 31 3
      packages/app/src/server/models/growi-plugin.ts
  74. 13 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  75. 8 8
      packages/app/src/server/routes/apiv3/markdown-setting.js
  76. 1 64
      packages/app/src/server/routes/apiv3/security-setting.js
  77. 1 1
      packages/app/src/server/routes/index.js
  78. 0 44
      packages/app/src/server/routes/login-passport.js
  79. 0 54
      packages/app/src/server/service/passport.ts
  80. 112 7
      packages/app/src/server/service/plugin.ts
  81. 3 2
      packages/app/src/server/views/widget/page_alerts.html
  82. 38 6
      packages/app/src/services/renderer/renderer.tsx
  83. 12 6
      packages/app/src/stores/admin/customize.tsx
  84. 0 1
      packages/app/src/stores/context.tsx
  85. 21 0
      packages/app/src/styles/_mixins.scss
  86. 2 0
      packages/core/src/index.ts
  87. 17 0
      packages/core/src/interfaces/growi-theme-metadata.ts
  88. 10 0
      packages/core/src/interfaces/vite.ts
  89. 0 1
      packages/preset-themes/.eslintrc.js
  90. 82 0
      packages/preset-themes/src/consts/preset-themes.ts
  91. 3 2
      packages/preset-themes/src/index.ts
  92. 17 0
      packages/preset-themes/src/interfaces/growi-theme-metadata.ts
  93. 0 7
      packages/preset-themes/src/interfaces/manifest.ts
  94. 0 5
      packages/preset-themes/src/utils/index.ts
  95. 0 7
      yarn.lock

+ 0 - 1
packages/app/_obsolete/src/client/nologin.jsx

@@ -52,7 +52,6 @@ if (loginFormElem) {
     twitter: loginFormElem.dataset.isTwitterAuthEnabled === 'true',
     saml: loginFormElem.dataset.isSamlAuthEnabled === 'true',
     oidc: loginFormElem.dataset.isOidcAuthEnabled === 'true',
-    basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
   };
 
   Object.assign(componentMappings, {

+ 0 - 1
packages/app/package.json

@@ -146,7 +146,6 @@
     "passport": "^0.6.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
-    "passport-http": "^0.3.0",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",

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

@@ -157,13 +157,6 @@
       "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
       "updated_saml": "Succeeded to update SAML setting"
     },
-    "Basic": {
-      "enable_basic": "Enable Basic",
-      "name": "Basic Authentication",
-      "desc_1": "Login with <code>username</code> in Authorization header.",
-      "desc_2": "User will be automatically generated if not exist.",
-      "updated_basic": "Succeeded to update Basic setting"
-    },
     "OAuth": {
       "enable_oidc": "Enable OIDC",
       "register": "Register for %s",
@@ -891,7 +884,6 @@
     "USER_LOGIN_WITH_TWITTER": "Login with Twitter",
     "USER_LOGIN_WITH_OIDC": "Login with OIDC",
     "USER_LOGIN_WITH_SAML": "Login with SAML",
-    "USER_LOGIN_WITH_BASIC": "Login with BASIC",
     "USER_LOGIN_FAILURE": "Login failure",
     "USER_LOGOUT": "Logout",
     "USER_FOGOT_PASSWORD": "Request password reset",
@@ -969,9 +961,6 @@
     "ADMIN_AUTH_OIDC_ENABLED": "Enable OIDC auth",
     "ADMIN_AUTH_OIDC_DISABLED": "Disable OIDC auth",
     "ADMIN_AUTH_OIDC_UPDATE": "Update OIDC settings",
-    "ADMIN_AUTH_BASIC_ENABLED": "Enable BASIC auth",
-    "ADMIN_AUTH_BASIC_DISABLED": "Disable BASIC auth",
-    "ADMIN_AUTH_BASIC_UPDATE": "Update BASIC auth settings",
     "ADMIN_AUTH_GOOGLE_ENABLED": "Enable Google auth",
     "ADMIN_AUTH_GOOGLE_DISABLED": "Disable Google auth",
     "ADMIN_AUTH_GOOGLE_UPDATE": "Update Google auth settings",

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

@@ -114,7 +114,6 @@
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
-  "Basic authentication": "Basic authentication",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
   "Anyone with the link": "Anyone with the link",

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

@@ -165,13 +165,6 @@
       "attr_based_login_control_rule_exampl2": "<h5>エスケープの例</h5>ルールに URL を利用したい場合は、次のようにエスケープしてください:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
       "updated_saml": "Succeeded to update SAML setting"
     },
-    "Basic": {
-      "enable_basic": "Basic を有効にする",
-      "name": "Basic 認証",
-      "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
-      "desc_2": "ユーザーが存在しなかった場合は自動生成します。",
-      "updated_basic": "Basic認証 を更新しました"
-    },
     "OAuth": {
       "enable_oidc": "OIDC を有効にする",
       "register": "%sに登録",
@@ -899,7 +892,6 @@
     "USER_LOGIN_WITH_TWITTER": "Twitter 認証でログイン",
     "USER_LOGIN_WITH_OIDC": "OIDC 認証でログイン",
     "USER_LOGIN_WITH_SAML": "SAML 認証でログイン",
-    "USER_LOGIN_WITH_BASIC": "BASIC 認証でログイン",
     "USER_LOGIN_FAILURE": "ログイン失敗",
     "USER_LOGOUT": "ログアウト",
     "USER_FOGOT_PASSWORD": "パスワードリセットのリクエスト",
@@ -977,9 +969,6 @@
     "ADMIN_AUTH_OIDC_ENABLED": "OIDC 認証を有効",
     "ADMIN_AUTH_OIDC_DISABLED": "OIDC 認証を無効",
     "ADMIN_AUTH_OIDC_UPDATE": "OIDC 認証設定の更新",
-    "ADMIN_AUTH_BASIC_ENABLED": "BASIC 認証の有効",
-    "ADMIN_AUTH_BASIC_DISABLED": "BASIC 認証の無効",
-    "ADMIN_AUTH_BASIC_UPDATE": "BASIC 認証設定の更新",
     "ADMIN_AUTH_GOOGLE_ENABLED": "Google 認証の有効",
     "ADMIN_AUTH_GOOGLE_DISABLED": "Google 認証の無効",
     "ADMIN_AUTH_GOOGLE_UPDATE": "Google 認証設定の更新",

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

@@ -165,13 +165,6 @@
       "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
       "updated_saml": "Succeeded to update SAML setting"
 		},
-		"Basic": {
-			"enable_basic": "Enable Basic",
-			"name": "Basic Authentication",
-			"desc_1": "Login with <code>username</code> in Authorization header.",
-			"desc_2": "User will be automatically generated if not exist.",
-			"updated_basic": "Succeeded to update Basic setting"
-		},
 		"OAuth": {
 			"enable_oidc": "Enable OIDC",
 			"register": "Register for %s",
@@ -899,7 +892,6 @@
     "USER_LOGIN_WITH_TWITTER": "使用 Twitter 登录",
     "USER_LOGIN_WITH_OIDC": "使用 OIDC 登录",
     "USER_LOGIN_WITH_SAML": "使用 SAML 登录",
-    "USER_LOGIN_WITH_BASIC": "使用 BASIC 登录",
     "USER_LOGIN_FAILURE": "登录失败",
     "USER_LOGOUT": "注销",
     "USER_FOGOT_PASSWORD": "要求重置密码",
@@ -977,9 +969,6 @@
     "ADMIN_AUTH_OIDC_ENABLED": "启用 OIDC 身份验证",
     "ADMIN_AUTH_OIDC_DISABLED": "禁用 OIDC 身份验证",
     "ADMIN_AUTH_OIDC_UPDATE": "更新 OIDC 设置",
-    "ADMIN_AUTH_BASIC_ENABLED": "启用基本身份验证",
-    "ADMIN_AUTH_BASIC_DISABLED": "禁用基本身份验证",
-    "ADMIN_AUTH_BASIC_UPDATE": "更新基本认证设置",
     "ADMIN_AUTH_GOOGLE_ENABLED": "启用谷歌身份验证",
     "ADMIN_AUTH_GOOGLE_DISABLED": "禁用谷歌身份验证",
     "ADMIN_AUTH_GOOGLE_UPDATE": "更新谷歌授权设置",

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

@@ -121,7 +121,6 @@
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
 	"Basic Settings": "基础设置",
-	"Basic authentication": "基本身份验证",
 	"The contents entered here will be shown in the header etc": "此处输入的内容将显示在标题等中",
 	"Public": "公共",
 	"Anyone with the link": "任何人",

+ 0 - 77
packages/app/src/client/services/AdminBasicSecurityContainer.js

@@ -1,77 +0,0 @@
-import { isServer } from '@growi/core';
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-import { removeNullPropertyFromObject } from '~/utils/object-utils';
-
-import { apiv3Get, apiv3Put } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
-
-/**
- * Service container for admin security page (BasicSecuritySetting.jsx)
- * @extends {Container} unstated Container
- */
-export default class AdminBasicSecurityContainer extends Container {
-
-  constructor() {
-    super();
-
-    if (isServer()) {
-      return;
-    }
-
-    this.state = {
-      isSameUsernameTreatedAsIdenticalUser: false,
-    };
-  }
-
-  /**
-   * retrieve security data
-   */
-  async retrieveSecurityData() {
-    try {
-      const response = await apiv3Get('/security-setting/');
-      const { basicAuth } = response.data.securityParams;
-      this.setState({
-        isSameUsernameTreatedAsIdenticalUser: basicAuth.isSameUsernameTreatedAsIdenticalUser,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch data');
-    }
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'AdminBasicSecurityContainer';
-  }
-
-  /**
-   * Switch isSameUsernameTreatedAsIdenticalUser
-   */
-  switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
-  }
-
-  /**
-   * Update basicSetting
-   */
-  async updateBasicSetting() {
-    let requestParams = { isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser };
-
-    requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/basic', requestParams);
-    const { securitySettingParams } = response.data;
-
-    this.setState({
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
-    });
-    return response;
-  }
-
-}

+ 0 - 9
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -42,7 +42,6 @@ export default class AdminGeneralSecurityContainer extends Container {
       isLdapEnabled: false,
       isSamlEnabled: false,
       isOidcEnabled: false,
-      isBasicEnabled: false,
       isGoogleEnabled: false,
       isGitHubEnabled: false,
       isTwitterEnabled: false,
@@ -82,7 +81,6 @@ export default class AdminGeneralSecurityContainer extends Container {
       isLdapEnabled: generalAuth.isLdapEnabled,
       isSamlEnabled: generalAuth.isSamlEnabled,
       isOidcEnabled: generalAuth.isOidcEnabled,
-      isBasicEnabled: generalAuth.isBasicEnabled,
       isGoogleEnabled: generalAuth.isGoogleEnabled,
       isGitHubEnabled: generalAuth.isGitHubEnabled,
       isTwitterEnabled: generalAuth.isTwitterEnabled,
@@ -318,13 +316,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.switchAuthentication('isOidcEnabled', 'oidc');
   }
 
-  /**
-   * Switch Basic enabled
-   */
-  async switchIsBasicEnabled() {
-    this.switchAuthentication('isBasicEnabled', 'basic');
-  }
-
   /**
    * Switch GoogleOAuth enabled
    */

+ 1 - 4
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -27,7 +27,7 @@ export default class AdminMarkDownContainer extends Container {
       pageBreakSeparator: 1,
       pageBreakCustomSeparator: '',
       isEnabledXss: false,
-      xssOption: 1,
+      xssOption: '',
       tagWhiteList: '',
       attrWhiteList: '',
     };
@@ -86,9 +86,6 @@ export default class AdminMarkDownContainer extends Container {
    * Switch enableXss
    */
   switchEnableXss() {
-    if (this.state.isEnabledXss) {
-      this.setState({ xssOption: null });
-    }
     this.setState({ isEnabledXss: !this.state.isEnabledXss });
   }
 

+ 2 - 19
packages/app/src/client/services/activate-plugin.ts

@@ -1,9 +1,5 @@
-import { readFileSync } from 'fs';
-import path from 'path';
-
-import { GrowiPlugin } from '~/interfaces/plugin';
 import { initializeGrowiFacade } from '~/utils/growi-facade';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
+import loggerFactory from '~/utils/logger';
 
 
 declare global {
@@ -16,24 +12,11 @@ declare global {
   };
 }
 
-
-export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+const logger = loggerFactory('growi:cli:ActivatePluginService');
 
 
 export class ActivatePluginService {
 
-  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
-    const entries: GrowiPluginManifestEntries = [];
-
-    growiPlugins.forEach(async(growiPlugin) => {
-      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
-      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
-      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
-    });
-
-    return entries;
-  }
-
   static activateAll(): void {
     initializeGrowiFacade();
 

+ 12 - 5
packages/app/src/client/services/page-operation.ts

@@ -3,8 +3,8 @@ import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
@@ -175,16 +175,23 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
   };
 };
 
-export const useUpdateStateAfterSave = () => {
+export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
   const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
+  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+
+  if (pageId == null) { return }
 
   // update swr 'currentPageId', 'currentPage', remote states
-  return async(pageId: string) => {
+  return async() => {
     await mutateCurrentPageId(pageId);
     const updatedPage = await mutateCurrentPage();
 
+    await mutateTagsInfo(); // get from DB
+    syncTagsInfoForEditor(); // sync global state for client
+
     if (updatedPage == null) { return }
 
     const remoterevisionData = {
@@ -192,7 +199,7 @@ export const useUpdateStateAfterSave = () => {
       remoteRevisionBody: updatedPage.revision.body,
       remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
       remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
-      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced.toString(),
+      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced?.toString(),
       hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
     };
 

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

@@ -36,7 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{           'Plugins Extension'}</>;
+      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          'Plugins'}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */

+ 0 - 103
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -1,103 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { GrowiThemes } from '~/interfaces/theme';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import ThemeColorBox from './ThemeColorBox';
-
-/* eslint-disable no-multi-spaces */
-const lightNDarkTheme = [{
-  name: GrowiThemes.DEFAULT,      bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
-}, {
-  name: GrowiThemes.MONO_BLUE,    bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
-}, {
-  name: GrowiThemes.HUFFLEPUFF,   bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
-}, {
-  name: GrowiThemes.FIRE_RED,     bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
-}, {
-  name: GrowiThemes.JADE_GREEN,   bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
-}];
-
-const uniqueTheme = [{
-  name: GrowiThemes.NATURE,       bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
-}, {
-  name: GrowiThemes.WOOD,         bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
-}, {
-  name: GrowiThemes.ISLAND,       bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
-}, {
-  name: GrowiThemes.CHRISTMAS,    bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
-}, {
-  name: GrowiThemes.ANTARCTIC,    bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
-}, {
-  name: GrowiThemes.SPRING,       bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
-}, {
-  name: GrowiThemes.FUTURE,       bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
-}, {
-  name: GrowiThemes.HALLOWEEN,    bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
-}, {
-  name: GrowiThemes.KIBELA,       bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
-}, {
-  name: GrowiThemes.BLACKBOARD,   bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
-}];
-
-
-const CustomizeThemeOptions = (props) => {
-
-  const { selectedTheme } = props;
-
-  const { t } = useTranslation('admin');
-
-
-  return (
-    <div id="themeOptions">
-      {/* Light and Dark Themes */}
-      <div>
-        <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
-        <div className="d-flex flex-wrap">
-          {lightNDarkTheme.map((theme) => {
-            return (
-              <ThemeColorBox
-                key={theme.name}
-                isSelected={selectedTheme === theme.name}
-                onSelected={() => props.onSelected(theme.name)}
-                {...theme}
-              />
-            );
-          })}
-        </div>
-      </div>
-      {/* Unique Theme */}
-      <div className="mt-3">
-        <h3>{t('customize_settings.theme_desc.unique')}</h3>
-        <div className="d-flex flex-wrap">
-          {uniqueTheme.map((theme) => {
-            return (
-              <ThemeColorBox
-                key={theme.name}
-                isSelected={selectedTheme === theme.name}
-                onSelected={() => props.onSelected(theme.name)}
-                {...theme}
-              />
-            );
-          })}
-        </div>
-      </div>
-    </div>
-  );
-
-};
-
-const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
-
-CustomizeThemeOptions.propTypes = {
-  onSelected: PropTypes.func,
-  selectedTheme: PropTypes.string,
-};
-
-export default CustomizeThemeOptionsWrapper;

+ 67 - 0
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx

@@ -0,0 +1,67 @@
+import React, { useMemo } from 'react';
+
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import { ThemeColorBox } from './ThemeColorBox';
+
+
+type Props = {
+  availableThemes: GrowiThemeMetadata[],
+  selectedTheme?: string,
+  onSelected?: (themeName: string) => void,
+};
+
+const CustomizeThemeOptions = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const { availableThemes, selectedTheme, onSelected } = props;
+
+  const lightNDarkThemes = useMemo(() => {
+    return availableThemes.filter(s => s.schemeType === GrowiThemeSchemeType.BOTH);
+  }, [availableThemes]);
+  const oneModeThemes = useMemo(() => {
+    return availableThemes.filter(s => s.schemeType !== GrowiThemeSchemeType.BOTH);
+  }, [availableThemes]);
+
+  return (
+    <div id="themeOptions">
+      {/* Light and Dark Themes */}
+      <div>
+        <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
+        <div className="d-flex flex-wrap">
+          {lightNDarkThemes.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                metadata={theme}
+                onSelected={() => onSelected?.(theme.name)}
+              />
+            );
+          })}
+        </div>
+      </div>
+      {/* Only one mode Theme */}
+      <div className="mt-3">
+        <h3>{t('customize_settings.theme_desc.unique')}</h3>
+        <div className="d-flex flex-wrap">
+          {oneModeThemes.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={selectedTheme != null && selectedTheme === theme.name}
+                metadata={theme}
+                onSelected={() => onSelected?.(theme.name)}
+              />
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+
+};
+
+
+export default CustomizeThemeOptions;

+ 22 - 10
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,15 +1,17 @@
 import React, { useCallback, useEffect, useState } from 'react';
 
+import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
-import { useSWRxGrowiTheme } from '~/stores/admin/customize';
+import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 
+
 // eslint-disable-next-line @typescript-eslint/ban-types
 type Props = {
 }
@@ -18,26 +20,26 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentTheme, error } = useSWRxGrowiTheme();
-  const [selectedTheme, setSelectedTheme] = useState(currentTheme);
+  const { data, error } = useSWRxGrowiThemeSetting();
+  const [currentTheme, setCurrentTheme] = useState(data?.currentTheme);
 
   useEffect(() => {
-    setSelectedTheme(currentTheme);
-  }, [currentTheme]);
+    setCurrentTheme(data?.currentTheme);
+  }, [data?.currentTheme]);
 
   const selectedHandler = useCallback((themeName: string) => {
-    setSelectedTheme(themeName);
+    setCurrentTheme(themeName);
   }, []);
 
   const submitHandler = useCallback(async() => {
-    if (selectedTheme == null) {
+    if (currentTheme == null) {
       toastWarning('The selected theme is undefined');
       return;
     }
 
     try {
       await apiv3Put('/customize-setting/theme', {
-        theme: selectedTheme,
+        theme: currentTheme,
       });
 
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
@@ -45,13 +47,23 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [selectedTheme, t]);
+  }, [currentTheme, t]);
+
+  const availableThemes = data?.pluginThemesMetadatas == null
+    ? PresetThemesMetadatas
+    : PresetThemesMetadatas.concat(data.pluginThemesMetadatas);
+
+  const selectedTheme = availableThemes.find(t => t.name === currentTheme)?.name ?? PresetThemes.DEFAULT;
 
   return (
     <div className="row">
       <div className="col-12">
         <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
-        <CustomizeThemeOptions onSelected={selectedHandler} selectedTheme={selectedTheme} />
+        <CustomizeThemeOptions
+          onSelected={selectedHandler}
+          availableThemes={availableThemes}
+          selectedTheme={selectedTheme}
+        />
         <AdminUpdateButtonRow onClick={submitHandler} disabled={error != null} />
       </div>
     </div>

+ 0 - 47
packages/app/src/components/Admin/Customize/ThemeColorBox.jsx

@@ -1,47 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-
-class ThemeColorBox extends React.PureComponent {
-
-  render() {
-    const {
-      isSelected, onSelected, name, bg, topbar, sidebar, theme,
-    } = this.props;
-
-    return (
-      <div
-        id={`theme-option-${name}`}
-        className={`theme-option-container d-flex flex-column align-items-center ${isSelected && 'active'}`}
-        onClick={onSelected}
-      >
-        <a id={name} role="button" className={`m-0 ${name} theme-button`}>
-          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-            <g>
-              <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>
-              <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={topbar}></path>
-              <path d="M -1 15 L15 15 L15 65 L-1 65 L-1 15 Z" fill={sidebar}></path>
-              <path d="M 65 45 L65 65 L45 65 L65 45 Z" fill={theme}></path>
-            </g>
-          </svg>
-        </a>
-        <span className="theme-option-name"><b>{ name }</b></span>
-      </div>
-    );
-  }
-
-}
-
-
-ThemeColorBox.propTypes = {
-  isSelected: PropTypes.bool.isRequired,
-  onSelected: PropTypes.func.isRequired,
-  name: PropTypes.string.isRequired,
-  bg: PropTypes.string.isRequired,
-  topbar: PropTypes.string.isRequired,
-  sidebar: PropTypes.string.isRequired,
-  theme: PropTypes.string.isRequired,
-};
-
-export default ThemeColorBox;

+ 42 - 0
packages/app/src/components/Admin/Customize/ThemeColorBox.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import type { GrowiThemeMetadata } from '@growi/core';
+
+
+type Props = {
+  isSelected: boolean,
+  metadata: GrowiThemeMetadata,
+  onSelected?: () => void,
+};
+
+export const ThemeColorBox = (props: Props): JSX.Element => {
+
+  const {
+    isSelected, metadata, onSelected,
+  } = props;
+  const {
+    name, bg, topbar, sidebar, accent, isPresetTheme,
+  } = metadata;
+
+  return (
+    <div
+      id={`theme-option-${name}`}
+      className={`theme-option-container d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
+      onClick={onSelected}
+    >
+      <a id={name} role="button" className={`m-0 ${name} theme-button`}>
+        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+          <g>
+            <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>
+            <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={topbar}></path>
+            <path d="M -1 15 L15 15 L15 65 L-1 65 L-1 15 Z" fill={sidebar}></path>
+            <path d="M 65 45 L65 65 L45 65 L65 45 Z" fill={accent}></path>
+          </g>
+        </svg>
+      </a>
+      <span className="theme-option-name"><b>{ name }</b></span>
+      { !isPresetTheme && <span className='theme-option-badge badge badge-primary mt-1'>Plugin</span> }
+    </div>
+  );
+
+};

+ 9 - 8
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { tags, attrs } from '~/services/xss/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 
@@ -49,12 +50,12 @@ class XssForm extends React.Component {
               <input
                 type="radio"
                 className="custom-control-input"
-                id="xssOption2"
+                id="xssOption1"
                 name="XssOption"
-                checked={xssOption === 2}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
+                checked={xssOption === RehypeSanitizeOption.RECOMMENDED}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.RECOMMENDED }) }}
               />
-              <label className="custom-control-label w-100" htmlFor="xssOption2">
+              <label className="custom-control-label w-100" htmlFor="xssOption1">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
                 <div className="mt-4">
                   <div className="d-flex justify-content-between">
@@ -91,12 +92,12 @@ class XssForm extends React.Component {
               <input
                 type="radio"
                 className="custom-control-input"
-                id="xssOption3"
+                id="xssOption2"
                 name="XssOption"
-                checked={xssOption === 3}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
+                checked={xssOption === RehypeSanitizeOption.CUSTOM}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
               />
-              <label className="custom-control-label w-100" htmlFor="xssOption3">
+              <label className="custom-control-label w-100" htmlFor="xssOption2">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
               </label>

+ 0 - 42
packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,42 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-
-import PropTypes from 'prop-types';
-
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import { toastError } from '~/client/util/apiNotification';
-import { toArrayIfNot } from '~/utils/array-utils';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import BasicSecurityManagementContents from './BasicSecuritySettingContents';
-
-const BasicSecurityManagement = (props) => {
-  const { adminBasicSecurityContainer } = props;
-
-  const fetchBasicSecuritySettingsData = useCallback(async() => {
-    try {
-      await adminBasicSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      const errs = toArrayIfNot(err);
-      toastError(errs);
-    }
-  }, [adminBasicSecurityContainer]);
-
-  useEffect(() => {
-    fetchBasicSecuritySettingsData();
-  }, [adminBasicSecurityContainer, fetchBasicSecuritySettingsData]);
-
-
-  return <BasicSecurityManagementContents />;
-};
-
-BasicSecurityManagement.propTypes = {
-  adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
-};
-
-const BasicSecurityManagementWithUnstatedContainer = withUnstatedContainers(BasicSecurityManagement, [
-  AdminBasicSecurityContainer,
-]);
-
-export default BasicSecurityManagementWithUnstatedContainer;

+ 0 - 139
packages/app/src/components/Admin/Security/BasicSecuritySettingContents.jsx

@@ -1,139 +0,0 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class BasicSecurityManagementContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminBasicSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminBasicSecurityContainer.updateBasicSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.Basic.updated_basic'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
-    const { isBasicEnabled } = adminGeneralSecurityContainer.state;
-
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          { t('security_settings.Basic.name') }
-        </h2>
-
-        {adminBasicSecurityContainer.state.retrieveError != null && (
-          <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminBasicSecurityContainer.state.retrieveError}</p>
-          </div>
-        )}
-
-        <div className="form-group row">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                id="isBasicEnabled"
-                className="custom-control-input"
-                type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isBasicEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsBasicEnabled() }}
-              />
-              <label className="custom-control-label" htmlFor="isBasicEnabled">
-                { t('security_settings.Basic.enable_basic') }
-              </label>
-            </div>
-            <p className="form-text text-muted">
-              <small>
-                <span dangerouslySetInnerHTML={{ __html: t('security_settings.Basic.desc_1') }} /><br />
-                { t('security_settings.Basic.desc_2')}
-              </small>
-            </p>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('basic') && isBasicEnabled)
-            && <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        {isBasicEnabled && (
-          <React.Fragment>
-            <div className="row mb-5">
-              <div className="offset-md-3 col-md-6">
-                <div className="custom-control custom-checkbox custom-checkbox-success">
-                  <input
-                    id="bindByEmail-basic"
-                    className="custom-control-input"
-                    type="checkbox"
-                    checked={adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                    onChange={() => { adminBasicSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="custom-control-label"
-                    htmlFor="bindByEmail-basic"
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical', 'username') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn', 'username') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-4 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminBasicSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-
-          </React.Fragment>
-        )}
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-BasicSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
-};
-
-const BasicSecurityManagementContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-
-  return <BasicSecurityManagementContents t={t} {...props} />;
-};
-
-const BasicSecurityManagementContentsWrapper = withUnstatedContainers(BasicSecurityManagementContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminBasicSecurityContainer,
-]);
-
-export default BasicSecurityManagementContentsWrapper;

+ 4 - 13
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -6,7 +6,6 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 
-import BasicSecuritySetting from './BasicSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
@@ -51,30 +50,25 @@ const SecurityManagementContents = () => {
         i18n: 'OIDC',
         index: 3,
       },
-      passport_basic: {
-        Icon: () => <i className="fa fa-lock" />,
-        i18n: 'BASIC',
-        index: 4,
-      },
       passport_google: {
         Icon: () => <i className="fa fa-google" />,
         i18n: 'Google',
-        index: 5,
+        index: 4,
       },
       passport_github: {
         Icon: () => <i className="fa fa-github" />,
         i18n: 'GitHub',
-        index: 6,
+        index: 5,
       },
       passport_twitter: {
         Icon: () => <i className="fa fa-twitter" />,
         i18n: 'Twitter',
-        index: 7,
+        index: 6,
       },
       passport_facebook: {
         Icon: () => <i className="fa fa-facebook" />,
         i18n: '(TBD) Facebook',
-        index: 8,
+        index: 7,
       },
     };
   }, []);
@@ -126,9 +120,6 @@ const SecurityManagementContents = () => {
           <TabPane tabId="passport_oidc">
             {activeComponents.has('passport_oidc') && <OidcSecuritySetting />}
           </TabPane>
-          <TabPane tabId="passport_basic">
-            {activeComponents.has('passport_basic') && <BasicSecuritySetting />}
-          </TabPane>
           <TabPane tabId="passport_google">
             {activeComponents.has('passport_google') && <GoogleSecuritySetting />}
           </TabPane>

+ 2 - 2
packages/app/src/components/Layout/Admin.module.scss

@@ -267,12 +267,12 @@ $slack-work-space-name-card-border: #efc1f6;
       background-color: $gray-50;
       border: 1px solid $border-color;
     }
-    .theme-option-name {
+    .theme-option-name, .theme-option-badge {
       opacity: 0.3;
     }
     // style (active)
     .theme-option-container.active {
-      .theme-option-name {
+      .theme-option-name, .theme-option-badge {
         opacity: 1;
       }
     }

+ 0 - 4
packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -112,10 +112,6 @@
       rgba(#55a79a, 0.4),
       $gray-700,
     ),
-    'basic': (
-      rgba(#24292e, 0.4),
-      $gray-700,
-    ),
   );
 
   @each $label, $colors in $btn-fill-colors {

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

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

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

@@ -220,7 +220,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       twitter: 'twitter',
       oidc: 'openid',
       saml: 'key',
-      basic: 'lock',
     };
 
     return (

+ 2 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -188,7 +188,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const router = useRouter();
 
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
@@ -201,7 +202,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: shareLinkId } = useShareLinkId();
   const { data: isContainerFluid } = useIsContainerFluid();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();

+ 6 - 2
packages/app/src/components/Page.tsx

@@ -5,6 +5,7 @@ import React, {
 
 import EventEmitter from 'events';
 
+import { pagePathUtils } from '@growi/core';
 import { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -15,7 +16,7 @@ import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import {
-  useIsGuestUser, useShareLinkId,
+  useIsGuestUser, useShareLinkId, useCurrentPathname,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
@@ -59,10 +60,13 @@ export const Page = (props) => {
     tocRef.current = toc;
   }, []);
 
+  const { data: currentPathname } = useCurrentPathname();
+  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
+
   const { data: shareLinkId } = useShareLinkId();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);

+ 1 - 1
packages/app/src/components/PageAlert/PageRedirectedAlert.tsx

@@ -21,7 +21,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
     }
   }, [unlink]);
 
-  if (redirectFrom == null) {
+  if (redirectFrom == null || redirectFrom === '') {
     return <></>;
   }
 

+ 14 - 4
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -5,10 +5,13 @@ import { format } from 'date-fns';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
+import { toastError } from '~/client/util/apiNotification';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage } from '~/stores/page';
+import { useRedirectFrom } from '~/stores/page-redirect';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
+
 const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
@@ -31,6 +34,7 @@ export const TrashPageAlert = (): JSX.Element => {
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
+  const { unlink } = useRedirectFrom();
 
 
   const deleteUser = pageData?.deleteUser;
@@ -43,12 +47,18 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
-      // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
-      // See: https://github.com/weseek/growi/pull/7054
-      router.reload();
+      try {
+        unlink();
+        // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+        // See: https://github.com/weseek/growi/pull/7054
+        router.reload();
+      }
+      catch (err) {
+        toastError(err);
+      }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [openPutBackPageModal, pageId, pagePath, router]);
+  }, [openPutBackPageModal, pageId, pagePath, router, unlink]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 28 - 39
packages/app/src/components/PageEditor.tsx

@@ -69,7 +69,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -82,7 +82,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
@@ -94,7 +93,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
-  const updateStateAfterSave = useUpdateStateAfterSave();
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
   const currentRevisionId = currentPage?.revision?._id;
 
@@ -117,8 +116,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
-  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
-
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -152,18 +149,21 @@ const PageEditor = React.memo((): JSX.Element => {
 
   }, [socket, checkIsConflict]);
 
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   // register to facade
   useEffect(() => {
     // for markdownRenderer
@@ -189,30 +189,19 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
-    if (grantData == null || isSlackEnabled == null || currentPathname == null) {
+  const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
+    if (currentPathname == null || optionsToSave == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
     }
 
-    const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
-    const grantedGroup = grantData?.grantedGroup;
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled,
-      slackChannels,
-      grant: grant || 1,
-      pageTags: pageTags || [],
-      grantUserGroupId: grantedGroup?.id,
-      grantUserGroupName: grantedGroup?.name,
-      ...opts,
-    };
+    const options = Object.assign(optionsToSave, opts);
 
     try {
       const { page } = await saveOrUpdate(
         markdownToSave.current,
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        optionsToSave,
+        options,
       );
 
       return page;
@@ -232,9 +221,9 @@ const PageEditor = React.memo((): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
-  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+  const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
       return;
     }
@@ -248,10 +237,10 @@ const PageEditor = React.memo((): JSX.Element => {
       await router.push(`/${page._id}`);
     }
     else {
-      updateStateAfterSave(page._id);
+      updateStateAfterSave?.();
     }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
@@ -260,10 +249,10 @@ const PageEditor = React.memo((): JSX.Element => {
 
     const page = await save();
     if (page != null) {
-      updateStateAfterSave(page._id);
+      updateStateAfterSave?.();
       toastSuccess(t('toaster.save_succeeded'));
     }
-  }, [editorMode, save, t, useUpdateStateAfterSave]);
+  }, [editorMode, save, t, updateStateAfterSave]);
 
 
   /**
@@ -540,7 +529,7 @@ const PageEditor = React.memo((): JSX.Element => {
         isOpen={conflictDiffModalStatus?.isOpened}
         onClose={() => closeConflictDiffModal()}
         markdownOnEdit={markdownToPreview}
-        optionsToSave={undefined} // replace undefined
+        optionsToSave={optionsToSave}
         afterResolvedHandler={afterResolvedHandler}
       />
     </div>

+ 0 - 112
packages/app/src/components/PageEditor/Cheatsheet.jsx

@@ -1,112 +0,0 @@
-/* eslint-disable max-len */
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-class Cheatsheet extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="row small">
-        <div className="col-sm-6">
-          <h4>{t('sandbox.header')}</h4>
-          <ul className="hljs">
-            <li><code># </code>{t('sandbox.header_x', { index: '1' })}</li>
-            <li><code>## </code>{t('sandbox.header_x', { index: '2' })}</li>
-            <li><code>### </code>{t('sandbox.header_x', { index: '3' })}</li>
-          </ul>
-          <h4>{t('sandbox.block')}</h4>
-          <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
-          <ul className="hljs">
-            <li>text</li>
-            <li></li>
-            <li>text</li>
-          </ul>
-          <h4>{t('sandbox.line_break')}</h4>
-          <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
-          <ul className="hljs">
-            <li>text&nbsp;&nbsp;</li>
-            <li>text</li>
-          </ul>
-          <h4>{t('sandbox.typography')}</h4>
-          <ul className="hljs">
-            <li><i>*{t('sandbox.italics')}*</i></li>
-            <li><b>**{t('sandbox.bold')}**</b></li>
-            <li><i><b>***{t('sandbox.italic_bold')}***</b></i></li>
-            <li>~~{t('sandbox.strikethrough')}~~ =&lt; <s>{t('sandbox.strikethrough')}</s></li>
-          </ul>
-          <h4>{t('sandbox.link')}</h4>
-          <ul className="hljs">
-            <li>[Google](https://www.google.co.jp/)</li>
-            <li>[/Page1/ChildPage1]</li>
-          </ul>
-          <h4>{t('sandbox.code_highlight')}</h4>
-          <ul className="hljs">
-            <li>```javascript:index.js</li>
-            <li>writeCode();</li>
-            <li>```</li>
-          </ul>
-        </div>
-        <div className="col-sm-6">
-          <h4>{t('sandbox.list')}</h4>
-          <ul className="hljs">
-            <li>- {t('sandbox.unordered_list_x', { index: '1' })}</li>
-            <li>&nbsp;&nbsp;- {t('sandbox.unordered_list_x', { index: '1.1' })}</li>
-            <li>- {t('sandbox.unordered_list_x', { index: '2' })}</li>
-          </ul>
-          <ul className="hljs">
-            <li>1. {t('sandbox.ordered_list_x', { index: '1' })}</li>
-            <li>1. {t('sandbox.ordered_list_x', { index: '2' })}</li>
-          </ul>
-          <ul className="hljs">
-            <li>- [ ] {t('sandbox.task')}({t('sandbox.task_unchecked')})</li>
-            <li>- [x] {t('sandbox.task')}({t('sandbox.task_checked')})</li>
-          </ul>
-          <h4>{t('sandbox.quote')}</h4>
-          <ul className="hljs">
-            <li>&gt; {t('sandbox.quote1')}</li>
-            <li>&gt; {t('sandbox.quote2')}</li>
-          </ul>
-          <ul className="hljs">
-            <li>&gt;&gt; {t('sandbox.quote_nested')}</li>
-            <li>&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
-            <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
-          </ul>
-          <h4>{t('sandbox.table')}</h4>
-          <pre className="border-0">
-            |Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|<br />
-            |:----------|:---------:|----------:|<br />
-            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
-            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
-          </pre>
-          <h4>{t('sandbox.image')}</h4>
-          <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
-          <ul className="hljs">
-            <li>![ex](https://example.com/image.png)</li>
-          </ul>
-
-          <hr />
-          <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
-            <i className="icon-share-alt" /> {t('sandbox.open_sandbox')}
-          </a>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-Cheatsheet.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-const CheatsheetWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <Cheatsheet t={t} {...props} />;
-};
-
-export default CheatsheetWrapperFC;

+ 114 - 0
packages/app/src/components/PageEditor/Cheatsheet.tsx

@@ -0,0 +1,114 @@
+/* eslint-disable max-len */
+
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+export const Cheatsheet = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  /*
+  * Each Element
+  */
+  // Left Side
+  const codeStr = `# ${t('sandbox.header_x', { index: '1' })}\n## ${t('sandbox.header_x', { index: '2' })}\n### ${t('sandbox.header_x', { index: '3' })}`;
+  const codeBlockStr = 'text\n\ntext';
+  const lineBlockStr = 'text\ntext';
+  const typographyStr = `*${t('sandbox.italics')}*\n**${t('sandbox.bold')}**\n***${t('sandbox.italic_bold')}***\n~~${t('sandbox.strikethrough')}~~`;
+  const linkStr = '[Google](https://www.google.co.jp/)\n[/Page1/ChildPage1]';
+  const codeHighlightStr = '```javascript:index.js\nwriteCode();\n```';
+
+  // Right Side
+  const codeListStr = `- ${t('sandbox.unordered_list_x', { index: '1' })}
+    - ${t('sandbox.unordered_list_x', { index: '1.1' })}
+    - ${t('sandbox.unordered_list_x', { index: '1.2' })}`;
+  const orderedListStr = `1. ${t('sandbox.ordered_list_x', { index: '1' })}\n1. ${t('sandbox.ordered_list_x', { index: '2' })}`;
+  const taskStr = `- [ ] ${t('sandbox.task')}(${t('sandbox.task_unchecked')})\n- [x] ${t('sandbox.task')}(${t('sandbox.task_checked')})`;
+  const quoteStr = `> ${t('sandbox.quote1')}\n> ${t('sandbox.quote2')}`;
+  const nestedQuoteStr = `>> ${t('sandbox.quote_nested')}\n>>> ${t('sandbox.quote_nested')}\n>>>> ${t('sandbox.quote_nested')}`;
+  const tableStr = '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
+  const imageStr = '![ex](https://example.com/image.png)';
+
+
+  const renderCheetSheetElm = (CheetSheetElm: string) => {
+    return (
+      <PrismAsyncLight
+        className="code-highlighted"
+        PreTag="div"
+        style={oneDark}
+        language={'text'}
+      >
+        {String(CheetSheetElm).replace(/\n$/, '')}
+      </PrismAsyncLight>
+    );
+  };
+
+
+  return (
+    <div className="row small">
+      <div className="col-sm-6">
+
+        {/* Header */}
+        <h4>{t('sandbox.header')}</h4>
+        {renderCheetSheetElm(codeStr)}
+
+        {/* Block */}
+        <h4>{t('sandbox.block')}</h4>
+        <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
+        {renderCheetSheetElm(codeBlockStr)}
+
+        {/* Line Break */}
+        <h4>{t('sandbox.line_break')}</h4>
+        <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
+        {renderCheetSheetElm(lineBlockStr)}
+
+
+        {/* Typography */}
+        <h4>{t('sandbox.typography')}</h4>
+        {renderCheetSheetElm(typographyStr)}
+
+        {/* Link */}
+        <h4>{t('sandbox.link')}</h4>
+        {renderCheetSheetElm(linkStr)}
+
+        {/* CodeHhighlight */}
+        <h4>{t('sandbox.code_highlight')}</h4>
+        {renderCheetSheetElm(codeHighlightStr)}
+      </div>
+
+      <div className="col-sm-6">
+        {/* List */}
+        <h4>{t('sandbox.list')}</h4>
+        {renderCheetSheetElm(codeListStr)}
+
+        {renderCheetSheetElm(orderedListStr)}
+
+        {renderCheetSheetElm(taskStr)}
+
+        {/* Quote */}
+        <h4>{t('sandbox.quote')}</h4>
+        {renderCheetSheetElm(quoteStr)}
+
+        {renderCheetSheetElm(nestedQuoteStr)}
+
+
+        {/* Table */}
+        <h4>{t('sandbox.table')}</h4>
+        {renderCheetSheetElm(tableStr)}
+
+        {/* Image */}
+        <h4>{t('sandbox.image')}</h4>
+        <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
+        {renderCheetSheetElm(imageStr)}
+
+        <hr />
+        <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
+          <i className="icon-share-alt" /> {t('sandbox.open_sandbox')}
+        </a>
+      </div>
+    </div>
+  );
+
+};

+ 0 - 8
packages/app/src/components/PageEditor/Editor.module.scss

@@ -154,11 +154,3 @@
     }
   }
 }
-
-.modal-gfm-cheatsheet :global {
-  .modal-body {
-    .hljs {
-      font-family: var(--font-family-monospace);
-    }
-  }
-}

+ 2 - 2
packages/app/src/components/PageEditor/Editor.tsx

@@ -18,7 +18,7 @@ import { useIsMobile } from '~/stores/ui';
 import { IEditorMethods } from '../../interfaces/editor-methods';
 
 import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
+import { Cheatsheet } from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
@@ -246,7 +246,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     };
 
     return (
-      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`${styles['modal-gfm-cheatsheet']}`} >
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} >
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
           <i className="icon-fw icon-question" />Markdown help
         </ModalHeader>

+ 3 - 2
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
+import { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
@@ -12,7 +13,7 @@ import {
 } from '~/stores/ui';
 
 
-const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
 const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
@@ -129,7 +130,7 @@ const EditorNavbarBottom = (): JSX.Element => {
               )}
             </div>
           ))}
-          <SavePageControls />
+          <SavePageControls slackChannels={slackChannelsStr} />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>

+ 38 - 41
packages/app/src/components/PageEditorByHackmd.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useRef, useState, useEffect,
+  useCallback, useRef, useState, useEffect, useMemo,
 } from 'react';
 
 import EventEmitter from 'events';
@@ -19,13 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useRemoteRevisionId, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -56,12 +56,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
-  const { data: grant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
 
@@ -71,8 +70,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
-  const slackChannels = slackChannelsData?.toString();
-
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitializing, setIsInitializing] = useState(false);
   // for error
@@ -88,33 +85,38 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
 
-  const updateStateAfterSave = useUpdateStateAfterSave();
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.HackMD) { return }
 
     try {
-      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null
-          || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null) {
+      if (currentPathname == null || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null || optionsToSave == null) {
         throw new Error('Some materials to save are invalid');
       }
 
-      const optionsToSave: OptionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-        ...opts,
-      };
+      const options = Object.assign(optionsToSave, opts, { isSyncRevisionToHackmd: true });
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, options);
       await mutatePageData();
       await mutateTagsInfo();
 
@@ -125,7 +127,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         await router.push(`/${page._id}`);
       }
       else {
-        updateStateAfterSave(page._id);
+        updateStateAfterSave?.();
       }
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
@@ -135,7 +137,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, revisionIdHackmdSynced, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -160,6 +162,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
     };
   }, [resetInitializedStatusHandler]);
 
+  useEffect(() => {
+    // for page translation: https://github.com/weseek/growi/pull/7100
+    setIsInitialized(false);
+  }, [pageId]);
+
 
   const isResume = useCallback(() => {
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
@@ -238,25 +245,18 @@ export const PageEditorByHackmd = (): JSX.Element => {
     try {
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       if (
-        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
-        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+        pageId == null || revisionIdHackmdSynced == null || currentPagePathOrPathname == null || optionsToSave == null
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-      };
-      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+
+      const options = Object.assign(optionsToSave, { isSyncRevisionToHackmd: true });
+
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, options);
 
       // update pageData
       mutatePageData(res);
 
       // set updated data
-      updateStateAfterSave(res._id);
+      updateStateAfterSave?.();
       mutateTagsInfo();
 
       logger.debug('success to save');
@@ -267,10 +267,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [
-    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced,
-    pageTags, saveOrUpdate, mutatePageData, useUpdateStateAfterSave, mutateTagsInfo, t,
-  ]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 5 - 3
packages/app/src/components/PageStatusAlert.tsx

@@ -10,6 +10,7 @@ import {
 import { useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import { Username } from './User/Username';
 
@@ -29,6 +30,7 @@ export const PageStatusAlert = (): JSX.Element => {
   const { data: isConflict } = useIsConflict();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { mutate: mutateEditorMode } = useEditorMode();
 
   // store remote latest page data
   const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
@@ -72,12 +74,12 @@ export const PageStatusAlert = (): JSX.Element => {
           {t('hackmd.this_page_has_draft')}
         </>,
       btn:
-        <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
+        <button onClick={() => mutateEditorMode(EditorMode.HackMD)} className="btn btn-outline-white">
           <i className="fa fa-fw fa-file-text-o mr-1"></i>
           Open HackMD Editor
-        </a>,
+        </button>,
     };
-  }, [t]);
+  }, [mutateEditorMode, t]);
 
   const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
 

+ 10 - 5
packages/app/src/components/SavePageControls.tsx

@@ -30,7 +30,12 @@ const logger = loggerFactory('growi:SavePageControls');
 
 const { isTopPage } = pagePathUtils;
 
-export const SavePageControls = (): JSX.Element | null => {
+export type SavePageControlsProps = {
+  slackChannels: string
+}
+
+export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
+  const { slackChannels } = props;
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
@@ -45,13 +50,13 @@ export const SavePageControls = (): JSX.Element | null => {
 
   const save = useCallback(async(): Promise<void> => {
     // save
-    globalEmitter.emit('saveAndReturnToView');
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { slackChannels });
+  }, [slackChannels]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
+  }, [slackChannels]);
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {

+ 4 - 2
packages/app/src/components/Sidebar.tsx

@@ -17,6 +17,7 @@ import {
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { SidebarNav } from './Sidebar/SidebarNav';
+import { SidebarSkeleton } from './Sidebar/Skeleton/SidebarSkeleton';
 import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 
 import styles from './Sidebar.module.scss';
@@ -57,8 +58,9 @@ const GlobalNavigation = () => {
 
 const SidebarContentsWrapper = () => {
   const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
-    .then(mod => mod.StickyStretchableScroller), { ssr: false });
-  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
+    .then(mod => mod.StickyStretchableScroller), { ssr: false, loading: () => <SidebarSkeleton /> });
+  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents')
+    .then(mod => mod.SidebarContents), { ssr: false, loading: () => <SidebarSkeleton /> });
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {

+ 12 - 0
packages/app/src/components/Sidebar/CustomSidebar.module.scss

@@ -1,7 +1,19 @@
 @use '~/styles/organisms/wiki-custom-sidebar.scss';
+@use '~/styles/mixins' as *;
 
 .grw-custom-sidebar-content :global {
   .wiki {
     @extend %grw-custom-sidebar-content;
   }
+
+  .grw-custom-sidebar-skeleton-text {
+    @include grw-skeleton-text($font-size:15px, $line-height:21.42px);
+    max-width: 160px;
+    margin: 15px 0;
+  }
+
+  .grw-custom-sidebar-skeleton-text-full {
+    @extend .grw-custom-sidebar-skeleton-text;
+    max-width: 100%;
+  }
 }

+ 11 - 15
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import CustomSidebarContentSkeleton from './Skeleton/CustomSidebarContentSkeleton';
 
 import styles from './CustomSidebar.module.scss';
 
@@ -19,11 +21,9 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 
 const SidebarNotFound = () => {
   return (
-    <div className="grw-sidebar-content-header h5 text-center p-3">
+    <div className="grw-sidebar-content-header h5 text-center py-3">
       <Link href="/Sidebar#edit">
-        <a href="/Sidebar#edit">
-          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-        </a>
+        <a><i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page</a>
       </Link>
     </div>
   );
@@ -43,28 +43,24 @@ const CustomSidebar: FC = () => {
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">
           {t('CustomSidebar')}
-          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+          <Link href="/Sidebar"><a className="h6 ml-2"><i className="icon-pencil"></i></a></Link>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
       </div>
 
       {
         isLoading && (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-          </div>
+          <CustomSidebarContentSkeleton />
         )
       }
 
       {
         (!isLoading && markdown != null) && (
-          <div className={`p-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+          <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
             <RevisionRenderer
               rendererOptions={rendererOptions}
               markdown={markdown}
@@ -78,7 +74,7 @@ const CustomSidebar: FC = () => {
           <SidebarNotFound />
         )
       }
-    </>
+    </div>
   );
 };
 

+ 22 - 20
packages/app/src/components/Sidebar/PageTree.tsx

@@ -10,6 +10,17 @@ import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 import ItemsTree from './PageTree/ItemsTree';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
+import PageTreeContentSkeleton from './Skeleton/PageTreeContentSkeleton';
+
+const PageTreeHeader = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-sidebar-content-header py-3 d-flex">
+      <h3 className="mb-0">{t('Page Tree')}</h3>
+    </div>
+  );
+};
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -24,14 +35,10 @@ const PageTree: FC = memo(() => {
 
   if (migrationStatus == null) {
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
-        <div className="text-muted text-center mt-3">
-          <i className="fa fa-lg fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </>
+      <div className="px-3">
+        <PageTreeHeader />
+        <PageTreeContentSkeleton />
+      </div>
     );
   }
 
@@ -39,15 +46,13 @@ const PageTree: FC = memo(() => {
     // TODO : improve design
     // Story : https://redmine.weseek.co.jp/issues/83755
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
+      <div className="px-3">
+        <PageTreeHeader />
         <div className="mt-5 mx-2 text-center">
           <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
           <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
         </div>
-      </>
+      </div>
     );
   }
 
@@ -61,11 +66,8 @@ const PageTree: FC = memo(() => {
   const path = currentPath || '/';
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Page Tree')}</h3>
-      </div>
-
+    <div className="px-3">
+      <PageTreeHeader />
       <ItemsTree
         isEnableActions={!isGuestUser}
         targetPath={path}
@@ -74,13 +76,13 @@ const PageTree: FC = memo(() => {
       />
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top p-3 w-100">
+        <div className="grw-pagetree-footer border-top py-3 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />
           </div>
         </div>
       )}
-    </>
+    </div>
   );
 });
 

+ 14 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

@@ -1,9 +1,22 @@
 @use '~/styles/variables' as var;
+@use '~/styles/mixins' as *;
 $grw-sidebar-content-header-height: 58px;
 $grw-sidebar-content-footer-height: 50px;
 $grw-pagetree-item-padding-left: 10px;
+$grw-pagetree-item-container-height: 40px;
 
 .grw-pagetree {
+
+  .grw-pagetree-item-skeleton-text {
+    @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height);
+    padding-left: 12px;
+  }
+
+  .grw-pagetree-item-skeleton-text-child {
+    @extend .grw-pagetree-item-skeleton-text;
+    padding-left: 12px + $grw-pagetree-item-padding-left;
+  }
+
   :global {
     min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
@@ -56,7 +69,7 @@ $grw-pagetree-item-padding-left: 10px;
     .grw-pagetree-item-container {
       .grw-triangle-container {
         min-width: 35px;
-        height: 40px;
+        height: $grw-pagetree-item-container-height;
       }
     }
   }

+ 4 - 2
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -24,6 +24,8 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import PageTreeContentSkeleton from '../Skeleton/PageTreeContentSkeleton';
+
 import Item from './Item';
 import { ItemNode } from './ItemNode';
 
@@ -272,7 +274,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group p-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
         <Item
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
@@ -288,7 +290,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     );
   }
 
-  return <></>;
+  return <PageTreeContentSkeleton />;
 };
 
 export default ItemsTree;

+ 17 - 0
packages/app/src/components/Sidebar/RecentChanges.module.scss

@@ -1,3 +1,5 @@
+@use '~/styles/mixins' as *;
+
 .grw-recent-changes-resize-button :global {
   font-size: 12px;
   line-height: normal;
@@ -15,6 +17,21 @@
 }
 
 .list-group-item :global {
+  .grw-recent-changes-skeleton-small {
+    @include grw-skeleton-text($font-size:14px, $line-height:16px);
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-h5 {
+    @include grw-skeleton-h5;
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-date {
+    @include grw-skeleton-text($font-size:10px, $line-height:12px);
+    width: 90px;
+  }
+
   .grw-recent-changes-item-lower {
     height: 17.5px;
   }

+ 43 - 67
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -16,6 +16,8 @@ import loggerFactory from '~/utils/logger';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 import InfiniteScroll from './InfiniteScroll';
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 
 import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
@@ -23,11 +25,15 @@ import styles from './RecentChanges.module.scss';
 
 const logger = loggerFactory('growi:History');
 
-type PageItemProps = {
+type PageItemLowerProps = {
   page: IPageHasId,
 }
 
-const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
+type PageItemProps = PageItemLowerProps & {
+  isSmall: boolean
+}
+
+const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
       <div className="d-flex">
@@ -44,8 +50,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
 });
 PageItemLower.displayName = 'PageItemLower';
 
-
-const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
+const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -75,67 +80,38 @@ const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
   });
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} py-3 px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-2">
+          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
           </h5>
-          <div className="grw-tag-labels mt-1 mb-2">
+          {!isSmall && <div className="grw-tag-labels mt-1 mb-2">
             { tagElements }
-          </div>
+          </div>}
           <PageItemLower page={page} />
         </div>
       </div>
     </li>
   );
 });
-LargePageItem.displayName = 'LargePageItem';
-
-
-const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className="grw-page-path-text-muted-container small">
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span><i className="icon-lock ml-2" /></span>;
-  }
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-        <div className="flex-grow-1 ml-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0 text-truncate">
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          <PageItemLower page={page} />
-        </div>
-      </div>
-    </li>
-  );
-});
-SmallPageItem.displayName = 'SmallPageItem';
+PageItem.displayName = 'PageItem';
 
 const RecentChanges = (): JSX.Element => {
+
   const PER_PAGE = 20;
   const { t } = useTranslation();
-  const swr = useSWRInifinitexRecentlyUpdated();
+  const swrInifinitexRecentlyUpdated = useSWRInifinitexRecentlyUpdated();
+  const { data: dataRecentlyUpdated, error, mutate: mutateRecentlyUpdated } = swrInifinitexRecentlyUpdated;
+
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-  const isEmpty = swr.data?.[0].length === 0;
-  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
+  const isEmpty = dataRecentlyUpdated?.[0].length === 0;
+  const isLoading = error == null && dataRecentlyUpdated === undefined;
+  const isReachingEnd = isEmpty || (dataRecentlyUpdated && dataRecentlyUpdated[dataRecentlyUpdated.length - 1]?.length < PER_PAGE);
+
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -153,12 +129,10 @@ const RecentChanges = (): JSX.Element => {
   }, [retrieveSizePreferenceFromLocalStorage]);
 
   return (
-    <div data-testid="grw-recent-changes">
-      <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+    <div className="px-3" data-testid="grw-recent-changes">
+      <div className="grw-sidebar-content-header py-3 d-flex">
+        <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
+        <SidebarHeaderReloadButton onClick={() => mutateRecentlyUpdated()}/>
         <div className="d-flex align-items-center">
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
             <input
@@ -173,21 +147,23 @@ const RecentChanges = (): JSX.Element => {
           </div>
         </div>
       </div>
-      <div className="grw-recent-changes p-3">
-        <ul className="list-group list-group-flush">
-          <InfiniteScroll
-            swrInifiniteResponse={swr}
-            isReachingEnd={isReachingEnd}
-          >
-            {pages => pages.map(page => (
-              isRecentChangesSidebarSmall
-                ? <SmallPageItem key={page._id} page={page} />
-                : <LargePageItem key={page._id} page={page} />
-            ))
-            }
-          </InfiniteScroll>
-        </ul>
-      </div>
+      {
+        isLoading ? <RecentChangesContentSkeleton /> : (
+          <div className="grw-recent-changes py-3">
+            <ul className="list-group list-group-flush">
+              <InfiniteScroll
+                swrInifiniteResponse={swrInifinitexRecentlyUpdated}
+                isReachingEnd={isReachingEnd}
+              >
+                {pages => pages.map(
+                  page => <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />,
+                )
+                }
+              </InfiniteScroll>
+            </ul>
+          </div>
+        )
+      }
     </div>
   );
 

+ 14 - 0
packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+
+type Props = {
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+};
+
+export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+
+  return (
+    <button type="button" className="btn btn-sm ml-auto py-0 grw-btn-reload" onClick={onClick}>
+      <i className="icon icon-reload"></i>
+    </button>
+  );
+};

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../CustomSidebar.module.scss';
+
+const CustomSidebarContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text ${styles['grw-custom-sidebar-skeleton-text']}`} />
+    </div>
+  );
+};
+
+export default CustomSidebarContentSkeleton;

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../PageTree/ItemsTree.module.scss';
+
+const PageTreeContentSkeleton = (): JSX.Element => {
+
+  return (
+    <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} >
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+    </ul>
+  );
+};
+
+export default PageTreeContentSkeleton;

+ 40 - 0
packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../RecentChanges.module.scss';
+
+const SkeletonItem = () => {
+
+  const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
+
+  return (
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+      <div className="d-flex w-100">
+        <Skeleton additionalClass='rounded-circle picture' roundedPill />
+        <div className="flex-grow-1 ml-2">
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
+            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+          </div>
+        </div>
+      </div>
+    </li>
+  );
+};
+
+const RecentChangesContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className="grw-recent-changes py-3">
+      <ul className="list-group list-group-flush">
+        <SkeletonItem />
+        <SkeletonItem />
+        <SkeletonItem />
+        <li className={'list-group-item p-0'}></li>
+      </ul>
+    </div>);
+};
+
+export default RecentChangesContentSkeleton;

+ 6 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss

@@ -0,0 +1,6 @@
+@use '~/styles/mixins' as *;
+
+.grw-sidebar-content-header-skeleton {
+  @include grw-skeleton-h3;
+  max-width: 100%;
+}

+ 50 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import CustomSidebarContentSkeleton from './CustomSidebarContentSkeleton';
+import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
+import TagContentSkeleton from './TagContentSkeleton';
+
+import styles from './SidebarSkeleton.module.scss';
+
+export const SidebarHeaderSkeleton = (): JSX.Element => {
+  return (
+    <div className="grw-sidebar-content-header py-3">
+      <Skeleton additionalClass={styles['grw-sidebar-content-header-skeleton']} />
+    </div>
+  );
+};
+
+export const SidebarSkeleton = (): JSX.Element => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let SidebarContentSkeleton: () => JSX.Element;
+  switch (currentSidebarContents) {
+
+    case SidebarContentsType.TAG:
+      SidebarContentSkeleton = TagContentSkeleton;
+      break;
+    case SidebarContentsType.RECENT:
+      SidebarContentSkeleton = RecentChangesContentSkeleton;
+      break;
+    case SidebarContentsType.CUSTOM:
+      SidebarContentSkeleton = CustomSidebarContentSkeleton;
+      break;
+    case SidebarContentsType.TREE:
+    default:
+      SidebarContentSkeleton = PageTreeContentSkeleton;
+      break;
+  }
+
+  return (
+    <div className={currentSidebarContents === SidebarContentsType.TAG ? 'px-4' : 'px-3'}>
+      <SidebarHeaderSkeleton />
+      <SidebarContentSkeleton />
+    </div>
+  );
+};

+ 23 - 0
packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../Tag.module.scss';
+
+export const TagListSkeleton = (): JSX.Element => {
+  return (
+    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+  );
+};
+
+const TagContentSkeleton = (): JSX.Element => {
+
+  return (
+    <>
+      <Skeleton additionalClass={`${styles['grw-tag-skeleton-h3']} my-3`} />
+      <TagListSkeleton />
+    </>
+  );
+};
+
+export default TagContentSkeleton;

+ 10 - 0
packages/app/src/components/Sidebar/Tag.module.scss

@@ -0,0 +1,10 @@
+@use '~/styles/mixins' as *;
+
+.grw-tag-skeleton-h3 {
+  @include grw-skeleton-h3;
+  max-width: 120px;
+}
+
+.grw-tag-list-skeleton {
+  height: 90px;
+}

+ 5 - 10
packages/app/src/components/Sidebar/Tag.tsx

@@ -9,6 +9,9 @@ import { useSWRxTagsList } from '~/stores/tag';
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
+
 
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
@@ -44,22 +47,14 @@ const Tag: FC = () => {
     <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
-        <button
-          type="button"
-          className="btn btn-sm ml-auto grw-btn-reload"
-          onClick={onReload}
-        >
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => onReload()}/>
       </div>
 
       <h3 className="my-3">{t('tag_list')}</h3>
 
       { isLoading
         ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
+          <TagListSkeleton />
         )
         : (
           <div data-testid="grw-tags-list">

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

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill ?? ''}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
     </div>
   );
 };

+ 0 - 12
packages/app/src/interfaces/activity.ts

@@ -16,7 +16,6 @@ const ACTION_USER_LOGIN_WITH_GITHUB = 'USER_LOGIN_WITH_GITHUB';
 const ACTION_USER_LOGIN_WITH_TWITTER = 'USER_LOGIN_WITH_TWITTER';
 const ACTION_USER_LOGIN_WITH_OIDC = 'USER_LOGIN_WITH_OIDC';
 const ACTION_USER_LOGIN_WITH_SAML = 'USER_LOGIN_WITH_SAML';
-const ACTION_USER_LOGIN_WITH_BASIC = 'USER_LOGIN_WITH_BASIC';
 const ACTION_USER_LOGIN_FAILURE = 'USER_LOGIN_FAILURE';
 const ACTION_USER_LOGOUT = 'USER_LOGOUT';
 const ACTION_USER_FOGOT_PASSWORD = 'USER_FOGOT_PASSWORD';
@@ -94,9 +93,6 @@ const ACTION_ADMIN_AUTH_SAML_UPDATE = 'ADMIN_AUTH_SAML_UPDATE';
 const ACTION_ADMIN_AUTH_OIDC_ENABLED = 'ADMIN_AUTH_OIDC_ENABLED';
 const ACTION_ADMIN_AUTH_OIDC_DISABLED = 'ADMIN_AUTH_OIDC_DISABLED';
 const ACTION_ADMIN_AUTH_OIDC_UPDATE = 'ADMIN_AUTH_OIDC_UPDATE';
-const ACTION_ADMIN_AUTH_BASIC_ENABLED = 'ADMIN_AUTH_BASIC_ENABLED';
-const ACTION_ADMIN_AUTH_BASIC_DISABLED = 'ADMIN_AUTH_BASIC_DISABLED';
-const ACTION_ADMIN_AUTH_BASIC_UPDATE = 'ADMIN_AUTH_BASIC_UPDATE';
 const ACTION_ADMIN_AUTH_GOOGLE_ENABLED = 'ADMIN_AUTH_GOOGLE_ENABLED';
 const ACTION_ADMIN_AUTH_GOOGLE_DISABLED = 'ADMIN_AUTH_GOOGLE_DISABLED';
 const ACTION_ADMIN_AUTH_GOOGLE_UPDATE = 'ADMIN_AUTH_GOOGLE_UPDATE';
@@ -199,7 +195,6 @@ export const SupportedAction = {
   ACTION_USER_LOGIN_WITH_TWITTER,
   ACTION_USER_LOGIN_WITH_OIDC,
   ACTION_USER_LOGIN_WITH_SAML,
-  ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
   ACTION_USER_FOGOT_PASSWORD,
@@ -277,9 +272,6 @@ export const SupportedAction = {
   ACTION_ADMIN_AUTH_OIDC_ENABLED,
   ACTION_ADMIN_AUTH_OIDC_DISABLED,
   ACTION_ADMIN_AUTH_OIDC_UPDATE,
-  ACTION_ADMIN_AUTH_BASIC_ENABLED,
-  ACTION_ADMIN_AUTH_BASIC_DISABLED,
-  ACTION_ADMIN_AUTH_BASIC_UPDATE,
   ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
   ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
   ACTION_ADMIN_AUTH_GOOGLE_UPDATE,
@@ -383,7 +375,6 @@ export const SmallActionGroup = {
   ACTION_USER_LOGIN_WITH_TWITTER,
   ACTION_USER_LOGIN_WITH_OIDC,
   ACTION_USER_LOGIN_WITH_SAML,
-  ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
   ACTION_PAGE_CREATE,
@@ -468,9 +459,6 @@ export const LargeActionGroup = {
   ACTION_ADMIN_AUTH_OIDC_ENABLED,
   ACTION_ADMIN_AUTH_OIDC_DISABLED,
   ACTION_ADMIN_AUTH_OIDC_UPDATE,
-  ACTION_ADMIN_AUTH_BASIC_ENABLED,
-  ACTION_ADMIN_AUTH_BASIC_DISABLED,
-  ACTION_ADMIN_AUTH_BASIC_UPDATE,
   ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
   ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
   ACTION_ADMIN_AUTH_GOOGLE_UPDATE,

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

@@ -1,7 +1,10 @@
+import { GrowiThemeMetadata } from '@growi/core';
+
 export type IResLayoutSetting = {
   isContainerFluid: boolean,
 };
 
 export type IResGrowiTheme = {
-  theme: string,
+  currentTheme: string,
+  pluginThemesMetadatas: GrowiThemeMetadata[],
 }

+ 8 - 2
packages/app/src/interfaces/plugin.ts

@@ -1,3 +1,5 @@
+import { GrowiThemeMetadata } from '@growi/core';
+
 export const GrowiPluginResourceType = {
   Template: 'template',
   Style: 'style',
@@ -12,11 +14,11 @@ export type GrowiPluginOrigin = {
   ghTag?: string,
 }
 
-export type GrowiPlugin = {
+export type GrowiPlugin<M extends GrowiPluginMeta = GrowiPluginMeta> = {
   isEnabled: boolean,
   installedPath: string,
   origin: GrowiPluginOrigin,
-  meta: GrowiPluginMeta,
+  meta: M,
 }
 
 export type GrowiPluginMeta = {
@@ -25,3 +27,7 @@ export type GrowiPluginMeta = {
   desc?: string,
   author?: string,
 }
+
+export type GrowiThemePluginMeta = GrowiPluginMeta & {
+  themes: GrowiThemeMetadata[]
+}

+ 5 - 0
packages/app/src/interfaces/rehype.ts

@@ -4,3 +4,8 @@ export const RehypeSanitizeOption = {
 } as const;
 
 export type RehypeSanitizeOption = typeof RehypeSanitizeOption[keyof typeof RehypeSanitizeOption];
+
+export type RehypeSanitizeOptionConfig = {
+  isEnabledXssPrevention: boolean,
+  // Todo add types for custom sanitize option at https://redmine.weseek.co.jp/issues/109763
+}

+ 3 - 1
packages/app/src/interfaces/services/renderer.ts

@@ -1,5 +1,7 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
+import { RehypeSanitizeOptionConfig } from '../rehype';
+
 
 export type RendererConfig = {
   isEnabledLinebreaks: boolean,
@@ -10,4 +12,4 @@ export type RendererConfig = {
 
   plantumlUri: string | null,
   blockdiagUri: string | null,
-} & XssOptionConfig;
+} & XssOptionConfig & RehypeSanitizeOptionConfig;

+ 0 - 19
packages/app/src/interfaces/theme.ts

@@ -1,22 +1,3 @@
-export const GrowiThemes = {
-  DEFAULT: 'default',
-  ANTARCTIC: 'antarctic',
-  BLACKBOARD: 'blackboard',
-  CHRISTMAS: 'christmas',
-  FIRE_RED: 'fire-red',
-  FUTURE: 'future',
-  HALLOWEEN: 'halloween',
-  HUFFLEPUFF: 'hufflepuff',
-  ISLAND: 'island',
-  JADE_GREEN: 'jade-green',
-  KIBELA: 'kibela',
-  MONO_BLUE: 'mono-blue',
-  NATURE: 'nature',
-  SPRING: 'spring',
-  WOOD: 'wood',
-} as const;
-export type GrowiThemes = typeof GrowiThemes[keyof typeof GrowiThemes];
-
 export const PrismThemes = {
   OneLight: 'one-light',
 } as const;

+ 25 - 0
packages/app/src/migrations/20221219011829-remove-basic-auth-related-config.js

@@ -0,0 +1,25 @@
+// eslint-disable-next-line import/no-named-as-default
+import Config from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:remove-basic-auth-related-config');
+
+const mongoose = require('mongoose');
+
+module.exports = {
+  async up() {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    await Config.findOneAndDelete({ key: 'security:passport-basic:isEnabled' });
+    await Config.findOneAndDelete({ key: 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser' });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down() {
+    // No rollback
+  },
+};

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

@@ -261,7 +261,7 @@ const Page: NextPage<Props> = (props: Props) => {
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
   useRemoteRevisionId(pageWithMeta?.data.revision._id);
   usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
-  useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd);
+  useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
 
@@ -556,10 +556,12 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
     // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
     attrWhiteList: crowi.xssService.getAttrWhiteList(),
     tagWhiteList: crowi.xssService.getTagWhiteList(),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+
+    // XSS: rehype-sanitize options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
   };
 
   props.sidebarConfig = {

+ 56 - 54
packages/app/src/pages/_document.page.tsx

@@ -1,85 +1,82 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
-import type { PresetThemesManifest } from '@growi/preset-themes';
-import { getManifestKeyFromTheme } from '@growi/preset-themes';
-import mongoose from 'mongoose';
+import type { ViteManifest } from '@growi/core';
+import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
 } from 'next/document';
 
-import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
-import type { GrowiThemes } from '~/interfaces/theme';
+import type { IPluginService, GrowiPluginResourceEntries } from '~/server/service/plugin';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
 
-type HeadersForPresetThemesProps = {
-  theme: GrowiThemes,
-  manifest: PresetThemesManifest,
+type HeadersForThemesProps = {
+  theme: string,
+  presetThemesManifest: ViteManifest,
+  pluginThemeHref: string | undefined,
 }
-const HeadersForPresetThemes = (props: HeadersForPresetThemesProps): JSX.Element => {
-  const { theme, manifest } = props;
-
-  let themeKey = getManifestKeyFromTheme(theme);
-  if (!(themeKey in manifest)) {
-    logger.warn(`The key for '${theme} does not exist in preset-themes manifest`);
-    themeKey = getManifestKeyFromTheme('default');
-  }
-  const href = `/static/preset-themes/${manifest[themeKey].file}`; // configured by express.static
+const HeadersForThemes = (props: HeadersForThemesProps): JSX.Element => {
+  const {
+    theme, presetThemesManifest, pluginThemeHref,
+  } = props;
 
   const elements: JSX.Element[] = [];
 
-  elements.push(
-    <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
-  );
+  // when plugin theme is specified
+  if (pluginThemeHref != null) {
+    elements.push(
+      <link rel="stylesheet" key={`link_custom-themes-${theme}`} href={pluginThemeHref} />,
+    );
+  }
+  // preset theme
+  else {
+    const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
+    const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
+    if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
+      logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
+    }
+    const href = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
+    elements.push(
+      <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
+    );
+  }
 
   return <>{elements}</>;
 };
 
 type HeadersForGrowiPluginProps = {
-  pluginManifestEntries: GrowiPluginManifestEntries;
+  pluginResourceEntries: GrowiPluginResourceEntries;
 }
 const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
-  const { pluginManifestEntries } = props;
+  const { pluginResourceEntries } = props;
 
   return (
     <>
-      { pluginManifestEntries.map(([growiPlugin, manifest]) => {
-        const { types } = growiPlugin.meta;
-
-        const elements: JSX.Element[] = [];
-
-        // add script
-        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
-          elements.push(<>
-            {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
-            <script type="module" key={`script_${growiPlugin.installedPath}`}
-              src={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
-          </>);
+      { pluginResourceEntries.map(([installedPath, href]) => {
+        if (href.endsWith('.js')) {
+          // eslint-disable-next-line @next/next/no-sync-scripts
+          return <script type="module" key={`script_${installedPath}`} src={href} />;
         }
-        // add link
-        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
-          elements.push(<>
-            <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
-              href={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
-          </>);
+        if (href.endsWith('.css')) {
+          // eslint-disable-next-line @next/next/no-sync-scripts
+          return <link rel="stylesheet" key={`link_${installedPath}`} href={href} />;
         }
-
-        return elements;
+        return <></>;
       }) }
     </>
   );
 };
 
 interface GrowiDocumentProps {
-  theme: GrowiThemes,
+  theme: string,
   customCss: string;
-  presetThemesManifest: PresetThemesManifest,
-  pluginManifestEntries: GrowiPluginManifestEntries;
+  presetThemesManifest: ViteManifest,
+  pluginThemeHref: string | undefined,
+  pluginResourceEntries: GrowiPluginResourceEntries;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
@@ -88,7 +85,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { configManager, customizeService } = crowi;
+    const { configManager, customizeService, pluginService } = crowi;
 
     const theme = configManager.getConfig('crowi', 'customize:theme');
     const customCss: string = customizeService.getCustomCss();
@@ -97,18 +94,22 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
 
     // retrieve plugin manifests
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
-    const growiPlugins = await GrowiPlugin.find({ isEnabled: true });
-    const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPlugins);
+    const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
+    const pluginThemeHref = await (pluginService as IPluginService).retrieveThemeHref(theme);
 
     return {
-      ...initialProps, theme, customCss, presetThemesManifest, pluginManifestEntries,
+      ...initialProps,
+      theme,
+      customCss,
+      presetThemesManifest,
+      pluginThemeHref,
+      pluginResourceEntries,
     };
   }
 
   override render(): JSX.Element {
     const {
-      customCss, theme, presetThemesManifest, pluginManifestEntries,
+      customCss, theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
     } = this.props;
 
     return (
@@ -127,8 +128,9 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
-          <HeadersForPresetThemes theme={theme} manifest={presetThemesManifest} />
-          <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
+          <HeadersForThemes theme={theme}
+            presetThemesManifest={presetThemesManifest} pluginThemeHref={pluginThemeHref} />
+          <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
         </Head>
         <body>
           <Main />

+ 1 - 1
packages/app/src/pages/admin/customize.page.tsx

@@ -15,7 +15,7 @@ import { useCustomizeTitle, useCurrentUser } from '~/stores/context';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
-const CustomizeSettingContents = dynamic(() => import('~/components/Admin//Customize/Customize'), { ssr: false });
+const CustomizeSettingContents = dynamic(() => import('~/components/Admin/Customize/Customize'), { ssr: false });
 
 
 type Props = CommonProps & {

+ 1 - 1
packages/app/src/pages/admin/plugins.page.tsx

@@ -27,7 +27,7 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
-  const title = 'Plugins Extension';
+  const title = 'Plugins';
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {

+ 0 - 4
packages/app/src/pages/admin/security.page.tsx

@@ -7,8 +7,6 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
-
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
@@ -52,7 +50,6 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
       const adminLdapSecurityContainer = new AdminLdapSecurityContainer();
       const adminSamlSecurityContainer = new AdminSamlSecurityContainer();
       const adminOidcSecurityContainer = new AdminOidcSecurityContainer();
-      const adminBasicSecurityContainer = new AdminBasicSecurityContainer();
       const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer();
       const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer();
       const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer();
@@ -63,7 +60,6 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
         adminLdapSecurityContainer,
         adminSamlSecurityContainer,
         adminOidcSecurityContainer,
-        adminBasicSecurityContainer,
         adminGoogleSecurityContainer,
         adminGitHubSecurityContainer,
         adminTwitterSecurityContainer,

+ 0 - 1
packages/app/src/pages/login.page.tsx

@@ -92,7 +92,6 @@ function injectEnabledStrategies(context: GetServerSidePropsContext, props: Prop
     twitter: configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
     saml: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
     oidc: configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
-    basic: configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
   };
 
   props.enabledStrategies = enabledStrategies;

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

@@ -354,7 +354,6 @@ Crowi.prototype.setupPassport = async function() {
     this.passportService.setupStrategyById('ldap');
     this.passportService.setupStrategyById('saml');
     this.passportService.setupStrategyById('oidc');
-    this.passportService.setupStrategyById('basic');
     this.passportService.setupStrategyById('google');
     this.passportService.setupStrategyById('github');
     this.passportService.setupStrategyById('twitter');

+ 2 - 8
packages/app/src/server/models/config.ts

@@ -1,8 +1,7 @@
+import { PresetThemes } from '@growi/preset-themes';
 import { Types, Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { GrowiThemes } from '~/interfaces/theme';
-
 import { RehypeSanitizeOption } from '../../interfaces/rehype';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
@@ -103,9 +102,6 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 
   'security:passport-oidc:isEnabled' : false,
 
-  'security:passport-basic:isEnabled' : false,
-  'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': false,
-
   'aws:s3Bucket'          : 'growi',
   'aws:s3Region'          : 'ap-northeast-1',
   'aws:s3AccessKeyId'     : undefined,
@@ -126,7 +122,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:title' : undefined,
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
-  'customize:theme' : GrowiThemes.DEFAULT,
+  'customize:theme' : PresetThemes.DEFAULT,
   'customize:isContainerFluid' : false,
   'customize:isEnabledTimeline' : true,
   'customize:isEnabledAttachTitleHeader' : false,
@@ -151,8 +147,6 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 };
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
-  'markdown:xss:isEnabledPrevention': true,
-  'markdown:xss:option': 2,
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:attrWhiteList': [],
   'markdown:rehypeSanitize:isEnabledPrevention': true,

+ 31 - 3
packages/app/src/server/models/growi-plugin.ts

@@ -1,18 +1,36 @@
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
   Schema, Model, Document,
 } from 'mongoose';
 
 import {
-  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType,
+  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
 } from '~/interfaces/plugin';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 export interface GrowiPluginDocument extends GrowiPlugin, Document {
 }
-export type GrowiPluginModel = Model<GrowiPluginDocument>
+export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
+  findEnabledPlugins(): Promise<GrowiPlugin[]>
+  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+}
+
+const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
+  name: { type: String, required: true },
+  manifestKey: { type: String, required: true },
+  schemeType: {
+    type: String,
+    enum: GrowiThemeSchemeType,
+    require: true,
+  },
+  bg: { type: String, required: true },
+  topbar: { type: String, required: true },
+  sidebar: { type: String, required: true },
+  accent: { type: String, required: true },
+});
 
-const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
+const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -21,6 +39,7 @@ const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
   },
   desc: { type: String },
   author: { type: String },
+  themes: [growiThemeMetadataSchema],
 });
 
 const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
@@ -36,5 +55,14 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
+growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
+  return this.find({ isEnabled: true });
+};
+growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
+  return this.find({
+    isEnabled: true,
+    'meta.types': { $in: types },
+  });
+};
 
 export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 13 - 2
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,8 +1,10 @@
 /* eslint-disable no-unused-vars */
 
 import { ErrorV3 } from '@growi/core';
+import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GrowiPluginResourceType } from '~/interfaces/plugin';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -271,8 +273,17 @@ module.exports = (crowi) => {
   router.get('/theme', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     try {
-      const theme = await crowi.configManager.getConfig('crowi', 'customize:theme');
-      return res.apiv3({ theme });
+      const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
+
+      // retrieve plugin manifests
+      const GrowiPluginModel = mongoose.model('GrowiPlugin');
+      const themePlugins = await GrowiPluginModel.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+
+      const pluginThemesMetadatas = themePlugins
+        .map(themePlugin => themePlugin.meta.themes)
+        .flat();
+
+      return res.apiv3({ currentTheme, pluginThemesMetadatas });
     }
     catch (err) {
       const msg = 'Error occurred in getting theme';

+ 8 - 8
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -125,8 +125,8 @@ module.exports = (crowi) => {
       isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
-      isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-      xssOption: await crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+      isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+      xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
       tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
       attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
     };
@@ -293,17 +293,17 @@ module.exports = (crowi) => {
     }
 
     const reqestXssParams = {
-      'markdown:xss:isEnabledPrevention': req.body.isEnabledXss,
-      'markdown:xss:option': req.body.xssOption,
-      'markdown:xss:tagWhiteList': req.body.tagWhiteList,
-      'markdown:xss:attrWhiteList': req.body.attrWhiteList,
+      'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
+      'markdown:rehypeSanitize:option': req.body.xssOption,
+      'markdown:xss:tagWhiteList': req.body.tagWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
+      'markdown:xss:attrWhiteList': req.body.attrWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
     };
 
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('markdown', reqestXssParams);
       const xssParams = {
-        isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-        xssOption: await crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+        isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+        xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
       };

+ 1 - 64
packages/app/src/server/routes/apiv3/security-setting.js

@@ -34,7 +34,7 @@ const validator = {
   authenticationSetting: [
     body('isEnabled').if(value => value != null).isBoolean(),
     body('authId').isString().isIn([
-      'local', 'ldap', 'saml', 'oidc', 'basic', 'google', 'github', 'twitter',
+      'local', 'ldap', 'saml', 'oidc', 'google', 'github', 'twitter',
     ]),
   ],
   localSetting: [
@@ -91,9 +91,6 @@ const validator = {
     body('isSameUsernameTreatedAsIdenticalUser').if(value => value != null).isBoolean(),
     body('isSameEmailTreatedAsIdenticalUser').if(value => value != null).isBoolean(),
   ],
-  basicAuth: [
-    body('isSameUsernameTreatedAsIdenticalUser').if(value => value != null).isBoolean(),
-  ],
   googleOAuth: [
     body('googleClientId').if(value => value != null).isString(),
     body('googleClientSecret').if(value => value != null).isString(),
@@ -291,12 +288,6 @@ const validator = {
  *          isSameEmailTreatedAsIdenticalUser:
  *            type: boolean
  *            description: local account automatically linked the email matched
- *      BasicAuthSetting:
- *        type: object
- *        properties:
- *          isSameUsernameTreatedAsIdenticalUser:
- *            type: boolean
- *            description: local account automatically linked the email matched
  *      GitHubOAuthSetting:
  *        type: object
  *        properties:
@@ -398,7 +389,6 @@ module.exports = (crowi) => {
         isLdapEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'),
         isSamlEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
         isOidcEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
-        isBasicEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
         isGoogleEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
         isGitHubEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
         isTwitterEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
@@ -461,9 +451,6 @@ module.exports = (crowi) => {
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       },
-      basicAuth: {
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
-      },
       googleOAuth: {
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
@@ -562,13 +549,6 @@ module.exports = (crowi) => {
           }
           parameters.action = SupportedAction.ACTION_ADMIN_AUTH_OIDC_DISABLED;
           break;
-        case 'basic':
-          if (isEnabled) {
-            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_BASIC_ENABLED;
-            break;
-          }
-          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_BASIC_DISABLED;
-          break;
         case 'google':
           if (isEnabled) {
             parameters.action = SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_ENABLED;
@@ -1100,49 +1080,6 @@ module.exports = (crowi) => {
     }
   });
 
-  /**
-   * @swagger
-   *
-   *    /_api/v3/security-setting/basic:
-   *      put:
-   *        tags: [SecuritySetting, apiv3]
-   *        description: Update basic
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/BasicAuthSetting'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update basic
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/BasicAuthSetting'
-   */
-  router.put('/basic', loginRequiredStrictly, adminRequired, addActivity, validator.basicAuth, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
-    };
-
-    try {
-      await updateAndReloadStrategySettings('basic', requestParams);
-
-      const securitySettingParams = {
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_BASIC_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating basicAuth';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-basicOAuth-failed'));
-    }
-  });
-
   /**
    * @swagger
    *

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -96,7 +96,6 @@ module.exports = function(crowi, app) {
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter, loginPassport.loginFailureForExternalAccount);
   app.get('/passport/oidc'                        , loginPassport.loginWithOidc, loginPassport.loginFailureForExternalAccount);
   app.get('/passport/saml'                        , loginPassport.loginWithSaml, loginPassport.loginFailureForExternalAccount);
-  app.get('/passport/basic'                       , loginPassport.loginWithBasic, loginPassport.loginFailureForExternalAccount);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailureForExternalAccount);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailureForExternalAccount);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailureForExternalAccount);
@@ -197,6 +196,7 @@ module.exports = function(crowi, app) {
     .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
     .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
 
+  app.get('/share$', (req, res) => res.redirect('/'));
   app.get('/share/:linkId', next.delegateToNext);
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));

+ 0 - 44
packages/app/src/server/routes/login-passport.js

@@ -626,49 +626,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  /**
-   * middleware that login with BasicStrategy
-   * @param {*} req
-   * @param {*} res
-   * @param {*} next
-   */
-  const loginWithBasic = async(req, res, next) => {
-    if (!passportService.isBasicStrategySetup) {
-      debug('BasicStrategy has not been set up');
-      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'Basic' });
-      return next(error);
-    }
-
-    const providerId = 'basic';
-    const strategyName = 'basic';
-    let userId;
-
-    try {
-      userId = await promisifiedPassportAuthentication(strategyName, req, res);
-    }
-    catch (err) {
-      return next(new ExternalAccountLoginError(err.message));
-    }
-
-    const userInfo = {
-      id: userId,
-      username: userId,
-      name: userId,
-    };
-
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
-    if (!externalAccount) {
-      return next(new ExternalAccountLoginError('message.sign_in_failure'));
-    }
-
-    const user = await externalAccount.getPopulatedUser();
-    await req.logIn(user, (err) => {
-      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
-
-      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_BASIC, true);
-    });
-  };
-
   return {
     cannotLoginErrorHadnler,
     loginFailure,
@@ -681,7 +638,6 @@ module.exports = function(crowi, app) {
     loginWithTwitter,
     loginWithOidc,
     loginWithSaml,
-    loginWithBasic,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,

+ 0 - 54
packages/app/src/server/service/passport.ts

@@ -7,7 +7,6 @@ import pRetry from 'p-retry';
 import passport from 'passport';
 import { Strategy as GitHubStrategy } from 'passport-github';
 import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
-import { BasicStrategy } from 'passport-http';
 import LdapStrategy from 'passport-ldapauth';
 import { Strategy as LocalStrategy } from 'passport-local';
 import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
@@ -76,11 +75,6 @@ class PassportService implements S2sMessageHandlable {
    */
   isSamlStrategySetup = false;
 
-  /**
-   * the flag whether BasicStrategy is set up successfully
-   */
-  isBasicStrategySetup = false;
-
   /**
    * the flag whether serializer/deserializer are set up successfully
    */
@@ -115,10 +109,6 @@ class PassportService implements S2sMessageHandlable {
       setup: 'setupOidcStrategy',
       reset: 'resetOidcStrategy',
     },
-    basic: {
-      setup: 'setupBasicStrategy',
-      reset: 'resetBasicStrategy',
-    },
     google: {
       setup: 'setupGoogleStrategy',
       reset: 'resetGoogleStrategy',
@@ -193,7 +183,6 @@ class PassportService implements S2sMessageHandlable {
     if (this.isLdapStrategySetup) { setupStrategies.push('ldap') }
     if (this.isSamlStrategySetup) { setupStrategies.push('saml') }
     if (this.isOidcStrategySetup) { setupStrategies.push('oidc') }
-    if (this.isBasicStrategySetup) { setupStrategies.push('basic') }
     if (this.isGoogleStrategySetup) { setupStrategies.push('google') }
     if (this.isGitHubStrategySetup) { setupStrategies.push('github') }
     if (this.isTwitterStrategySetup) { setupStrategies.push('twitter') }
@@ -991,49 +980,6 @@ class PassportService implements S2sMessageHandlable {
     return result;
   }
 
-  /**
-   * reset BasicStrategy
-   *
-   * @memberof PassportService
-   */
-  resetBasicStrategy() {
-    logger.debug('BasicStrategy: reset');
-    passport.unuse('basic');
-    this.isBasicStrategySetup = false;
-  }
-
-  /**
-   * setup BasicStrategy
-   *
-   * @memberof PassportService
-   */
-  setupBasicStrategy() {
-
-    this.resetBasicStrategy();
-
-    const configManager = this.crowi.configManager;
-    const isBasicEnabled = configManager.getConfig('crowi', 'security:passport-basic:isEnabled');
-
-    // when disabled
-    if (!isBasicEnabled) {
-      return;
-    }
-
-    logger.debug('BasicStrategy: setting up..');
-
-    passport.use(new BasicStrategy(
-      (userId, password, done) => {
-        if (userId != null) {
-          return done(null, userId);
-        }
-        return done(null, false, { message: 'Incorrect credentials.' });
-      },
-    ));
-
-    this.isBasicStrategySetup = true;
-    logger.debug('BasicStrategy: setup is done');
-  }
-
   /**
    * setup serializer and deserializer
    *

+ 112 - 7
packages/app/src/server/service/plugin.ts

@@ -1,16 +1,21 @@
-import fs from 'fs';
+import fs, { readFileSync } from 'fs';
 import path from 'path';
 
+import { GrowiThemeMetadata, ViteManifest } from '@growi/core';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
-import type { GrowiPlugin, GrowiPluginOrigin } from '~/interfaces/plugin';
+import {
+  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
+} from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
+import type { GrowiPluginModel } from '../models/growi-plugin';
+
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
@@ -18,8 +23,24 @@ const pluginStoringPath = resolveFromRoot('tmp/plugins');
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
+const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
+
+export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-export class PluginService {
+
+function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
+  const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+  const manifestStr: string = readFileSync(manifestPath, 'utf-8');
+  return JSON.parse(manifestStr);
+}
+
+export interface IPluginService {
+  install(origin: GrowiPluginOrigin): Promise<void>
+  retrieveThemeHref(theme: string): Promise<string | undefined>
+  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
+}
+
+export class PluginService implements IPluginService {
 
   async install(origin: GrowiPluginOrigin): Promise<void> {
     // download
@@ -48,7 +69,7 @@ export class PluginService {
     return;
   }
 
-  async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
+  private async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
 
     const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
     const unzippedPath = path.join(pluginStoringPath, ghOrganizationName);
@@ -109,15 +130,15 @@ export class PluginService {
     return;
   }
 
-  async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
+  private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
     const GrowiPlugin = mongoose.model('GrowiPlugin');
     await GrowiPlugin.insertMany(plugins);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+  private static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
     const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json');
-    const packageJson = await import(packageJsonPath);
+    const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
 
     const { growiPlugin } = packageJson;
     const {
@@ -155,6 +176,14 @@ export class PluginService {
       },
     };
 
+    // add theme metadata
+    if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
+      (plugin as GrowiPlugin<GrowiThemePluginMeta>).meta = {
+        ...plugin.meta,
+        themes: growiPlugin.themes,
+      };
+    }
+
     logger.info('Plugin detected => ', plugin);
 
     return [plugin];
@@ -164,4 +193,80 @@ export class PluginService {
     return [];
   }
 
+
+  async retrieveThemeHref(theme: string): Promise<string | undefined> {
+
+    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+
+    let matchedPlugin: GrowiPlugin | undefined;
+    let matchedThemeMetadata: GrowiThemeMetadata | undefined;
+
+    try {
+      // retrieve plugin manifests
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as GrowiPlugin<GrowiThemePluginMeta>[];
+
+      growiPlugins
+        .forEach(async(growiPlugin) => {
+          const themeMetadatas = growiPlugin.meta.themes;
+          const themeMetadata = themeMetadatas.find(t => t.name === theme);
+
+          // found
+          if (themeMetadata != null) {
+            matchedPlugin = growiPlugin;
+            matchedThemeMetadata = themeMetadata;
+          }
+        });
+    }
+    catch (e) {
+      logger.error(`Could not find the theme '${theme}' from GrowiPlugin documents.`, e);
+    }
+
+    try {
+      if (matchedPlugin != null && matchedThemeMetadata != null) {
+        const manifest = await retrievePluginManifest(matchedPlugin);
+        return `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
+      }
+    }
+    catch (e) {
+      logger.error(`Could not read manifest file for the theme '${theme}'`, e);
+    }
+  }
+
+  async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
+
+    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+
+    const entries: GrowiPluginResourceEntries = [];
+
+    try {
+      const growiPlugins = await GrowiPlugin.findEnabledPlugins();
+
+      growiPlugins.forEach(async(growiPlugin) => {
+        try {
+          const { types } = growiPlugin.meta;
+          const manifest = await retrievePluginManifest(growiPlugin);
+
+          // add script
+          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
+            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
+            entries.push([growiPlugin.installedPath, href]);
+          }
+          // add link
+          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
+            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
+            entries.push([growiPlugin.installedPath, href]);
+          }
+        }
+        catch (e) {
+          logger.warn(e);
+        }
+      });
+    }
+    catch (e) {
+      logger.error('Could not retrieve GrowiPlugin documents.', e);
+    }
+
+    return entries;
+  }
+
 }

+ 3 - 2
packages/app/src/server/views/widget/page_alerts.html

@@ -28,7 +28,8 @@
       {% endif %}
     {% endif %}
 
-    {% if redirectFrom or req.query.redirectFrom %}
+    <!-- This code has been replaced to <PageRedirectedAlert /> -->
+    <!-- {% if redirectFrom or req.query.redirectFrom %}
     <div class="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
         {% set fromPath = req.query.redirectFrom %}
@@ -44,7 +45,7 @@
         </button>
       {% endif %}
     </div>
-    {% endif %}
+    {% endif %} -->
 
     {% if req.query.unlinked %}
     <div class="alert alert-info d-edit-none py-3 px-4">

+ 38 - 6
packages/app/src/services/renderer/renderer.tsx

@@ -335,6 +335,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     slug,
@@ -344,6 +348,7 @@ export const generateViewOptions = (
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
     [toc.rehypePluginStore, { storeTocNode }],
     // [autoLinkHeadings, {
@@ -373,7 +378,9 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -386,16 +393,23 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [toc.rehypePluginRestore, { tocNode }],
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 
@@ -417,6 +431,10 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -426,6 +444,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -436,7 +455,9 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     components.table = Table;
   }
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -458,6 +479,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption, addLineNumberAttribute.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -468,6 +493,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -493,12 +519,18 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 

+ 12 - 6
packages/app/src/stores/admin/customize.tsx

@@ -1,6 +1,6 @@
 import { useCallback } from 'react';
 
-import { SWRResponse } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
@@ -27,22 +27,28 @@ export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> &
   };
 };
 
-export const useSWRxGrowiTheme = (): SWRResponse<string, Error> => {
+export const useSWRxGrowiThemeSetting = (): SWRResponse<IResGrowiTheme, Error> => {
 
   const fetcher = useCallback(async() => {
     const res = await apiv3Get<IResGrowiTheme>('/customize-setting/theme');
-    return res.data.theme;
+    return res.data;
   }, []);
 
-  const swrResponse = useSWRImmutable('/customize-setting/theme', fetcher);
+  const swrResponse = useSWR('/customize-setting/theme', fetcher);
 
   const update = async(theme: string) => {
     await apiv3Put('/customize-setting/layout', { theme });
-    await swrResponse.mutate();
+
+    if (swrResponse.data == null) {
+      swrResponse.mutate();
+      return;
+    }
+
+    const newData = { ...swrResponse.data, currentTheme: theme };
     // The updateFn should be a promise or asynchronous function to handle the remote mutation
     // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
     // Moreover, `async() => false` does not work since it's too fast to be calculated.
-    await swrResponse.mutate(new Promise(r => setTimeout(() => r(theme), 10)), { optimisticData: () => theme });
+    await swrResponse.mutate(new Promise(r => setTimeout(() => r(newData), 10)), { optimisticData: () => newData });
   };
 
   return Object.assign(

+ 0 - 1
packages/app/src/stores/context.tsx

@@ -5,7 +5,6 @@ import useSWRImmutable from 'swr/immutable';
 import { SupportedActionType } from '~/interfaces/activity';
 import { EditorConfig } from '~/interfaces/editor-settings';
 import { RendererConfig } from '~/interfaces/services/renderer';
-import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';

+ 21 - 0
packages/app/src/styles/_mixins.scss

@@ -160,3 +160,24 @@
     content: $code;
   }
 }
+
+@mixin grw-skeleton-text($font-size, $line-height) {
+  height: $line-height;
+  padding: (($line-height - $font-size)  / 2) 0;
+}
+/*
+.example {
+  @include grw-skeleton-text($font-size:$size, $line-height:$height);
+  max-width: 100%;
+}
+*/
+
+// values from './bootstrap/override'
+
+@mixin grw-skeleton-h3 {
+  @include grw-skeleton-text(21px, 30px);
+}
+
+@mixin grw-skeleton-h5 {
+  @include grw-skeleton-text(16px, 18px);
+}

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

@@ -16,6 +16,7 @@ export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/growi-facade';
+export * from './interfaces/growi-theme-metadata';
 export * from './interfaces/has-object-id';
 export * from './interfaces/lang';
 export * from './interfaces/page';
@@ -24,6 +25,7 @@ export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/template';
 export * from './interfaces/user';
+export * from './interfaces/vite';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';

+ 17 - 0
packages/core/src/interfaces/growi-theme-metadata.ts

@@ -0,0 +1,17 @@
+export const GrowiThemeSchemeType = {
+  BOTH: 'both',
+  LIGHT: 'light',
+  DARK: 'dark',
+} as const;
+export type GrowiThemeSchemeType = typeof GrowiThemeSchemeType[keyof typeof GrowiThemeSchemeType];
+
+export type GrowiThemeMetadata = {
+  name: string,
+  manifestKey: string,
+  schemeType: GrowiThemeSchemeType,
+  bg: string,
+  topbar: string,
+  sidebar: string,
+  accent: string,
+  isPresetTheme?: boolean,
+};

+ 10 - 0
packages/core/src/interfaces/vite.ts

@@ -0,0 +1,10 @@
+export type ViteManifestValue = {
+  file: string,
+  src: string,
+  isEntry?: boolean,
+  css?: string[],
+}
+
+export type ViteManifest = {
+  [key: string]: ViteManifestValue,
+}

+ 0 - 1
packages/preset-themes/.eslintrc.js

@@ -1,6 +1,5 @@
 module.exports = {
   extends: [
-    'weseek/react',
     'weseek/typescript',
   ],
   env: {

+ 82 - 0
packages/preset-themes/src/consts/preset-themes.ts

@@ -0,0 +1,82 @@
+import { GrowiThemeMetadata, GrowiThemeSchemeType } from '../interfaces/growi-theme-metadata';
+
+const { BOTH, LIGHT, DARK } = GrowiThemeSchemeType;
+
+export const PresetThemes = {
+  DEFAULT: 'default',
+  ANTARCTIC: 'antarctic',
+  BLACKBOARD: 'blackboard',
+  CHRISTMAS: 'christmas',
+  FIRE_RED: 'fire-red',
+  FUTURE: 'future',
+  HALLOWEEN: 'halloween',
+  HUFFLEPUFF: 'hufflepuff',
+  ISLAND: 'island',
+  JADE_GREEN: 'jade-green',
+  KIBELA: 'kibela',
+  MONO_BLUE: 'mono-blue',
+  NATURE: 'nature',
+  SPRING: 'spring',
+  WOOD: 'wood',
+} as const;
+export type PresetThemes = typeof PresetThemes[keyof typeof PresetThemes];
+
+/* eslint-disable no-multi-spaces, */
+
+export const DefaultThemeMetadata: GrowiThemeMetadata = {
+  name: PresetThemes.DEFAULT,
+  manifestKey: `src/styles/${PresetThemes.DEFAULT}.scss`,
+  schemeType: BOTH,
+  bg: '#ffffff',
+  topbar: '#2a2929',
+  sidebar: '#122c55',
+  accent: '#209fd8',
+  isPresetTheme: true,
+};
+
+export const PresetThemesMetadatas: GrowiThemeMetadata[] = [
+  // support both of light and dark
+  DefaultThemeMetadata,
+  {
+    name: PresetThemes.MONO_BLUE,     schemeType: BOTH, bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', accent: '#00587A',
+  }, {
+    name: PresetThemes.HUFFLEPUFF,    schemeType: BOTH, bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', accent: '#993439',
+  }, {
+    name: PresetThemes.FIRE_RED,      schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#EA5532',
+  }, {
+    name: PresetThemes.JADE_GREEN,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#38B48B',
+  },
+  // light only
+  {
+    name: PresetThemes.NATURE,        schemeType: LIGHT, bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', accent: '#460039',
+  }, {
+    name: PresetThemes.WOOD,          schemeType: LIGHT, bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', accent: '#aaa45f',
+  }, {
+    name: PresetThemes.ISLAND,        schemeType: LIGHT, bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', accent: 'rgba(183, 226, 219, 1)',
+  }, {
+    name: PresetThemes.CHRISTMAS,     schemeType: LIGHT, bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', accent: '#d3c665',
+  }, {
+    name: PresetThemes.ANTARCTIC,     schemeType: LIGHT, bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', accent: '#fa9913',
+  }, {
+    name: PresetThemes.SPRING,        schemeType: LIGHT, bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', accent: '#67a856',
+  }, {
+    name: PresetThemes.KIBELA,        schemeType: LIGHT, bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', accent: '#b5cbf79c',
+  },
+  // dark only
+  {
+    name: PresetThemes.FUTURE,        schemeType: DARK, bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', accent: '#00b5b7',
+  }, {
+    name: PresetThemes.HALLOWEEN,     schemeType: DARK, bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', accent: '#e9af2b',
+  }, {
+    name: PresetThemes.BLACKBOARD,    schemeType: DARK, bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', accent: '#DA8506',
+  },
+]
+  // fill in missing information
+  .map((metadata) => {
+    return {
+      ...metadata,
+      isPresetTheme: true,
+      manifestKey: `src/styles/${metadata.name}.scss`,
+    };
+  });
+/* eslint-disable no-multi-spaces */

+ 3 - 2
packages/preset-themes/src/index.ts

@@ -1,2 +1,3 @@
-export * from './interfaces/manifest';
-export * from './utils';
+export * from './consts/preset-themes';
+
+export const manifestPath = 'dist/themes/manifest.json';

+ 17 - 0
packages/preset-themes/src/interfaces/growi-theme-metadata.ts

@@ -0,0 +1,17 @@
+export const GrowiThemeSchemeType = {
+  BOTH: 'both',
+  LIGHT: 'light',
+  DARK: 'dark',
+} as const;
+export type GrowiThemeSchemeType = typeof GrowiThemeSchemeType[keyof typeof GrowiThemeSchemeType];
+
+export type GrowiThemeMetadata = {
+  name: string,
+  manifestKey: string,
+  schemeType: GrowiThemeSchemeType,
+  bg: string,
+  topbar: string,
+  sidebar: string,
+  accent: string,
+  isPresetTheme?: boolean,
+};

+ 0 - 7
packages/preset-themes/src/interfaces/manifest.ts

@@ -1,7 +0,0 @@
-export type PresetThemesManifest = {
-  [key: string]: {
-    file: string,
-    src: string,
-    isEntry?: boolean,
-  }
-}

+ 0 - 5
packages/preset-themes/src/utils/index.ts

@@ -1,5 +0,0 @@
-export const manifestPath = 'dist/themes/manifest.json';
-
-export const getManifestKeyFromTheme = (theme: string): string => {
-  return `src/styles/${theme}.scss`;
-};

+ 0 - 7
yarn.lock

@@ -17563,13 +17563,6 @@ passport-google-oauth20@^2.0.0:
   dependencies:
     passport-oauth2 "1.x.x"
 
-passport-http@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/passport-http/-/passport-http-0.3.0.tgz#8ee53d4380be9c60df2151925029826f77115603"
-  integrity sha1-juU9Q4C+nGDfIVGSUCmCb3cRVgM=
-  dependencies:
-    passport-strategy "1.x.x"
-
 passport-ldapauth@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz#1432e8469de18bd46b5b39a46a866b416c1ddded"