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

Merge branch 'master' into fix/gw-7948-fix-dropdown-flickering

Mudana-Grune 2 лет назад
Родитель
Сommit
2cbdb9332f
44 измененных файлов с 602 добавлено и 772 удалено
  1. 1 1
      README.md
  2. 1 1
      README_JP.md
  3. 16 7
      apps/app/next.config.js
  4. 3 2
      apps/app/package.json
  5. 1 2
      apps/app/public/static/locales/en_US/admin.json
  6. 2 1
      apps/app/public/static/locales/en_US/commons.json
  7. 3 2
      apps/app/public/static/locales/en_US/translation.json
  8. 1 2
      apps/app/public/static/locales/ja_JP/admin.json
  9. 2 1
      apps/app/public/static/locales/ja_JP/commons.json
  10. 2 1
      apps/app/public/static/locales/ja_JP/translation.json
  11. 1 2
      apps/app/public/static/locales/zh_CN/admin.json
  12. 2 1
      apps/app/public/static/locales/zh_CN/commons.json
  13. 2 1
      apps/app/public/static/locales/zh_CN/translation.json
  14. 12 14
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  15. 20 3
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  16. 46 0
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  17. 1 1
      apps/app/src/components/Me/InAppNotificationSettings.tsx
  18. 33 14
      apps/app/src/components/PasswordResetRequestForm.tsx
  19. 6 0
      apps/app/src/interfaces/activity.ts
  20. 8 4
      apps/app/src/interfaces/in-app-notification.ts
  21. 15 0
      apps/app/src/models/serializers/in-app-notification-snapshot/user.ts
  22. 20 7
      apps/app/src/pages/forgot-password.page.tsx
  23. 19 8
      apps/app/src/server/routes/login.js
  24. 3 1
      apps/app/src/server/service/file-uploader/gcs.js
  25. 19 8
      apps/app/src/server/service/in-app-notification.ts
  26. 5 6
      apps/app/src/server/service/page.ts
  27. 10 4
      apps/app/src/server/service/search.ts
  28. 13 2
      apps/app/src/stores/in-app-notification.ts
  29. 2 4
      apps/app/test/integration/service/page.test.js
  30. 1 1
      package.json
  31. 1 1
      packages/presentation/package.json
  32. 14 0
      tools/replacer/.eslintrc.cjs
  33. 24 0
      tools/replacer/.gitignore
  34. 13 0
      tools/replacer/index.html
  35. 28 0
      tools/replacer/package.json
  36. 49 0
      tools/replacer/src/App.css
  37. 47 0
      tools/replacer/src/App.tsx
  38. 69 0
      tools/replacer/src/index.css
  39. 10 0
      tools/replacer/src/main.tsx
  40. 1 0
      tools/replacer/src/vite-env.d.ts
  41. 24 0
      tools/replacer/tsconfig.json
  42. 10 0
      tools/replacer/tsconfig.node.json
  43. 7 0
      tools/replacer/vite.config.ts
  44. 35 670
      yarn.lock

+ 1 - 1
README.md

@@ -101,7 +101,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 | `yarn app:server` | Launch GROWI app server                                 |
 | `yarn start`      | Invoke `yarn app:build` and `yarn app:server`           |
 
-For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup-v2/launch-system.html#list-of-npm-commands).
+For more info, see [GROWI Docs: List of npm Scripts](https://docs.growi.org/en/dev/startup-v5/start-development.html#list-of-npm-scripts).
 
 # Documentation
 

+ 1 - 1
README_JP.md

@@ -100,7 +100,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 | `yarn app:server` | GROWI app サーバーを起動します。                        |
 | `yarn start`      | `yarn app:build` と `yarn app:server` を呼び出します。  |
 
-詳しくは [GROWI Docs: List of npm Commands](https://docs.growi.org/ja/dev/startup-v2/launch-system.html#npm-コマンドリスト)をご覧ください。
+詳しくは [GROWI Docs: npm スクリプトリスト](https://docs.growi.org/ja/dev/startup-v5/start-development.html#npm-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%95%E3%82%9A%E3%83%88%E3%83%AA%E3%82%B9%E3%83%88)をご覧ください。
 
 # ドキュメント
 

+ 16 - 7
apps/app/next.config.js

@@ -85,16 +85,25 @@ module.exports = async(phase, { defaultConfig }) => {
 
     /** @param config {import('next').NextConfig} */
     webpack(config, options) {
-      // Avoid "Module not found: Can't resolve 'fs'"
-      // See: https://stackoverflow.com/a/68511591
       if (!options.isServer) {
+        // Avoid "Module not found: Can't resolve 'fs'"
+        // See: https://stackoverflow.com/a/68511591
         config.resolve.fallback.fs = false;
-      }
 
-      // See: https://webpack.js.org/configuration/externals/
-      // This provides a way of excluding dependencies from the output bundles
-      config.externals.push('dtrace-provider');
-      config.externals.push('mongoose');
+        // exclude packages from the output bundles
+        config.module.rules.push(
+          ...[
+            /dtrace-provider/,
+            /mongoose/,
+            /mathjax-full/, // required from marp
+          ].map((packageRegExp) => {
+            return {
+              test: packageRegExp,
+              use: 'null-loader',
+            };
+          }),
+        );
+      }
 
       // extract sourcemap
       if (options.dev) {

+ 3 - 2
apps/app/package.json

@@ -18,7 +18,6 @@
     "//// for development": "",
     "dev": "yarn cross-env NODE_ENV=development yarn ts-node-dev --inspect --transpile-only src/server/app.ts",
     "dev:styles-prebuilt": "yarn styles-prebuilt --mode dev",
-    "dev:analyze": "yarn cross-env ANALYZE=true yarn dev",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate": "yarn dev:migrate:status > tmp/cache/migration-status.out && yarn dev:migrate:up",
     "dev:migrate:create": "yarn dev:migrate-mongo create -f config/migrate-mongo-config.js",
@@ -144,6 +143,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "prom-client": "^14.1.1",
+    "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^5.2.2",
@@ -192,7 +192,7 @@
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.6"
+    "xss": "^1.0.14"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -225,6 +225,7 @@
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
+    "null-loader": "^4.0.1",
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "prettier": "^1.19.1",

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

@@ -106,8 +106,7 @@
       "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
       "email_authentication": "Email authentication on user registration",
       "enable_email_authentication": "Enable email authentication",
-      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
-      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration."
     },
     "ldap": {
       "enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -20,7 +20,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
-    "please_enable_mailer": "Please setup mailer first."
+    "please_enable_mailer": "Please setup mailer first.",
+    "password_reset_please_enable_mailer": "Please setup mailer first."
   },
   "headers": {
     "app_settings": "App Settings"

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

@@ -572,7 +572,7 @@
   "login": {
     "title": "Login",
     "sign_in_error": "Login error",
-    "registration_successful": "registration_successful. Please wait for administrator approval.",
+    "registration_successful": "Registration successful. Please wait for administrator approval.",
     "Setup": "Setup",
     "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
@@ -659,7 +659,8 @@
     "success_to_send_email": "Success to send email",
     "feature_is_unavailable": "This feature is unavailable.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
-    "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+    "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
+    "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
   },
   "emoji" :{
     "title": "Pick an Emoji",

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

@@ -114,8 +114,7 @@
       "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
       "email_authentication": "ユーザー登録時のメール認証",
       "enable_email_authentication": "メール認証を有効にする",
-      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。"
     },
     "ldap": {
       "enable_ldap": "LDAP を有効にする",

+ 2 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -20,7 +20,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
-    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。"
+    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
   },
   "headers": {
     "app_settings": "アプリ設定"

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

@@ -693,7 +693,8 @@
     "success_to_send_email": "メールを送信しました",
     "feature_is_unavailable": "この機能を利用することはできません。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
-    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
+    "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
   },
   "emoji" :{
     "title": "絵文字を選択",

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

@@ -114,8 +114,7 @@
       "password_reset_desc": "忘记密码时,用户可以自行重置",
       "email_authentication": "用户注册时的电子邮件身份验证",
       "enable_email_authentication": "启用电子邮件身份验证",
-      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -20,7 +20,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
-    "please_enable_mailer": "请先设置邮件程序。"
+    "please_enable_mailer": "请先设置邮件程序。",
+    "password_reset_please_enable_mailer": "请先设置邮件程序。"
   },
   "headers": {
     "app_settings": "系统设置"

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

@@ -665,7 +665,8 @@
     "success_to_send_email": "我发了一封电子邮件",
     "feature_is_unavailable": "此功能不可用",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
-    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
+    "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
   },
   "emoji" :{
     "title": "选择一个表情符号",

+ 12 - 14
apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 
-
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -52,17 +52,6 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
 
-        {!isMailerSetup && (
-          <div className="row">
-            <div className="col-12">
-              <div className="alert alert-danger">
-                <span>{t('security_settings.Local.need_complete_mail_setting_warning')}</span>
-                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
-              </div>
-            </div>
-          </div>
-        )}
-
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
             className="alert alert-info"
@@ -146,7 +135,6 @@ class LocalSecuritySettingContents extends React.Component {
                     </button>
                   </div>
                 </div>
-
                 <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
               </div>
             </div>
@@ -189,6 +177,14 @@ class LocalSecuritySettingContents extends React.Component {
                     {t('security_settings.Local.enable_password_reset_by_users')}
                   </label>
                 </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
+                  </div>
+                )}
                 <p className="form-text text-muted small">
                   {t('security_settings.Local.password_reset_desc')}
                 </p>
@@ -213,7 +209,9 @@ class LocalSecuritySettingContents extends React.Component {
                 {!isMailerSetup && (
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.please_enable_mailer')}</span>
-                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('app_setting.mail_settings')}</a>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
                   </div>
                 )}
                 <p className="form-text text-muted small">

+ 20 - 3
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -8,11 +8,12 @@ import { DropdownItem } from 'reactstrap';
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 // Change the display for each targetmodel
 import PageModelNotification from './PageNotification/PageModelNotification';
-
+import UserModelNotification from './PageNotification/UserModelNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -40,6 +41,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   const getActionUsers = () => {
+    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
+      return notification.target.username;
+    }
+
     const latestActionUsers = notification.actionUsers.slice(0, 3);
     const latestUsers = latestActionUsers.map((user) => {
       return `@${user.name}`;
@@ -75,7 +80,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         <div className="position-absolute" style={{ top: 10, left: 10 }}>
           <UserPicture user={actionUsers[1]} size="md" noTooltip />
         </div>
-
       </div>
     );
   };
@@ -139,6 +143,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       break;
+    case 'USER_REGISTRATION_APPROVAL_REQUEST':
+      actionMsg = 'requested registration approval';
+      actionIcon = 'icon-bubble';
+      break;
     default:
       actionMsg = '';
       actionIcon = '';
@@ -163,7 +171,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {notification.targetModel === 'Page' && (
+        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
           <PageModelNotification
             ref={notificationRef}
             notification={notification}
@@ -172,6 +180,15 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
             actionUsers={actionUsers}
           />
         )}
+        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
+          <UserModelNotification
+            ref={notificationRef}
+            notification={notification}
+            actionMsg={actionMsg}
+            actionIcon={actionIcon}
+            actionUsers={actionUsers}
+          />
+        )}
       </div>
     </TagElem>
   );

+ 46 - 0
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

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

+ 1 - 1
apps/app/src/components/Me/InAppNotificationSettings.tsx

@@ -2,7 +2,7 @@ import React, {
   FC, useState, useEffect, useCallback,
 } from 'react';
 
-import { pullAllBy } from 'lodash';
+import pullAllBy from 'lodash/pullAllBy';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';

+ 33 - 14
apps/app/src/components/PasswordResetRequestForm.tsx

@@ -5,10 +5,11 @@ import Link from 'next/link';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 const PasswordResetRequestForm: FC = () => {
   const { t } = useTranslation();
+  const { data: isMailerSetup } = useIsMailerSetup();
   const [email, setEmail] = useState('');
 
   const changeEmail = useCallback((inputValue) => {
@@ -33,20 +34,38 @@ const PasswordResetRequestForm: FC = () => {
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
-      <h3>{ t('forgot_password.password_reset_request_desc') }</h3>
-      <div className="form-group">
-        <div className="input-group">
-          <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
+      {!isMailerSetup ? (
+        <div className="alert alert-danger">
+          {t('forgot_password.please_enable_mailer_alert')}
         </div>
-      </div>
-      <div className="form-group">
-        <button
-          className="btn btn-lg btn-primary btn-block"
-          type="submit"
-        >
-          {t('forgot_password.send')}
-        </button>
-      </div>
+      ) : (
+        <>
+          <h1><i className="icon-lock large"></i></h1>
+          <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+          <h3>{t('forgot_password.password_reset_request_desc')}</h3>
+          <div className="form-group">
+            <div className="input-group">
+              <input
+                name="email"
+                placeholder="E-mail Address"
+                className="form-control"
+                type="email"
+                disabled={!isMailerSetup}
+                onChange={e => changeEmail(e.target.value)}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <button
+              className="btn btn-lg btn-primary btn-block"
+              type="submit"
+              disabled={!isMailerSetup}
+            >
+              {t('forgot_password.send')}
+            </button>
+          </div>
+        </>
+      )}
       <Link href='/login' prefetch={false}>
         <i className="icon-login mr-1" />{t('forgot_password.return_to_login')}
       </Link>

+ 6 - 0
apps/app/src/interfaces/activity.ts

@@ -4,10 +4,12 @@ import { IUser } from './user';
 
 // Model
 const MODEL_PAGE = 'Page';
+const MODEL_USER = 'User';
 const MODEL_COMMENT = 'Comment';
 
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
+const ACTION_USER_REGISTRATION_APPROVAL_REQUEST = 'USER_REGISTRATION_APPROVAL_REQUEST';
 const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
 const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
 const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
@@ -162,6 +164,7 @@ const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 
 export const SupportedTargetModel = {
   MODEL_PAGE,
+  MODEL_USER,
 } as const;
 
 export const SupportedEventModel = {
@@ -182,6 +185,7 @@ export const SupportedActionCategory = {
 
 export const SupportedAction = {
   ACTION_UNSETTLED,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_LOGIN_WITH_LOCAL,
   ACTION_USER_LOGIN_WITH_LDAP,
@@ -349,6 +353,7 @@ export const EssentialActionGroup = {
   ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
 } as const;
 
 export const ActionGroupSize = {
@@ -375,6 +380,7 @@ export const SmallActionGroup = {
 // SmallActionGroup + Action by all General Users - PAGE_VIEW
 export const MediumActionGroup = {
   ...SmallActionGroup,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_FOGOT_PASSWORD,
   ACTION_USER_RESET_PASSWORD,

+ 8 - 4
apps/app/src/interfaces/in-app-notification.ts

@@ -1,5 +1,7 @@
 import type { IPageSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import type { IUserSnapshot } from '~/models/serializers/in-app-notification-snapshot/user';
 
+import { SupportedTargetModelType, SupportedActionType } from './activity';
 import { IPage } from './page';
 import { IUser } from './user';
 
@@ -9,16 +11,18 @@ export enum InAppNotificationStatuses {
   STATUS_OPENED = 'OPENED',
 }
 
+// TODO: do not use any type
+// https://redmine.weseek.co.jp/issues/120632
 export interface IInAppNotification {
   user: IUser
-  targetModel: 'Page'
-  target: IPage
-  action: 'COMMENT' | 'LIKE'
+  targetModel: SupportedTargetModelType
+  target: any
+  action: SupportedActionType
   status: InAppNotificationStatuses
   actionUsers: IUser[]
   createdAt: Date
   snapshot: string
-  parsedSnapshot?: IPageSnapshot
+  parsedSnapshot?: any
 }
 
 /*

+ 15 - 0
apps/app/src/models/serializers/in-app-notification-snapshot/user.ts

@@ -0,0 +1,15 @@
+import type { IUser } from '~/interfaces/user';
+
+export interface IUserSnapshot {
+  username: string
+}
+
+export const stringifySnapshot = (user: IUser): string => {
+  return JSON.stringify({
+    username: user.username,
+  });
+};
+
+export const parseSnapshot = (snapshot: string): IUserSnapshot => {
+  return JSON.parse(snapshot);
+};

+ 20 - 7
apps/app/src/pages/forgot-password.page.tsx

@@ -1,18 +1,24 @@
 import React from 'react';
 
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
-import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { useIsMailerSetup } from '~/stores/context';
+
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps,
 } from './utils/commons';
 
 const PasswordResetRequestForm = dynamic(() => import('~/components/PasswordResetRequestForm'), { ssr: false });
 
-const ForgotPasswordPage: NextPage = () => {
-  const { t } = useTranslation();
+type Props = CommonProps & {
+  isMailerSetup: boolean,
+};
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  useIsMailerSetup(props.isMailerSetup);
 
   return (
     <div id="main" className="main">
@@ -21,8 +27,6 @@ const ForgotPasswordPage: NextPage = () => {
           <div className="row justify-content-md-center">
             <div className="col-md-6 mt-5">
               <div className="text-center">
-                <h1><i className="icon-lock large"></i></h1>
-                <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
                 <PasswordResetRequestForm />
               </div>
             </div>
@@ -34,11 +38,19 @@ const ForgotPasswordPage: NextPage = () => {
 };
 
 // eslint-disable-next-line max-len
-async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: CommonProps, namespacesRequired?: string[] | undefined): Promise<void> {
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
   const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { mailService } = crowi;
+
+  props.isMailerSetup = mailService.isMailerSetup;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const result = await getServerSideCommonProps(context);
 
@@ -48,8 +60,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     throw new Error('invalid getSSP result');
   }
 
-  const props: CommonProps = result.props as CommonProps;
+  const props: Props = result.props as Props;
 
+  injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation', 'commons']);
 
   return {

+ 19 - 8
apps/app/src/server/routes/login.js

@@ -1,4 +1,4 @@
-import { SupportedAction } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
 // disable all of linting
@@ -10,7 +10,7 @@ module.exports = function(crowi, app) {
   const path = require('path');
   const User = crowi.model('User');
   const {
-    configManager, appService, aclService, mailService,
+    configManager, appService, aclService, mailService, activityService,
   } = crowi;
   const activityEvent = crowi.event('activity');
 
@@ -42,12 +42,28 @@ module.exports = function(crowi, app) {
       .forEach(result => logger.error(result.reason));
   }
 
+  async function sendNotificationToAllAdmins(user) {
+    const adminUsers = await User.findAdmins();
+    const activity = await activityService.createActivity({
+      action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
+      target: user,
+      targetModel: SupportedTargetModel.MODEL_USER,
+    });
+    await activityEvent.emit('updated', activity, user, adminUsers);
+    return;
+  }
+
   const registerSuccessHandler = async function(req, res, userData, registrationMode) {
     const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
+    const isMailerSetup = mailService.isMailerSetup ?? false;
+
     if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-      await sendEmailToAllAdmins(userData);
+      sendNotificationToAllAdmins(userData);
+      if (isMailerSetup) {
+        await sendEmailToAllAdmins(userData);
+      }
       return res.apiv3({});
     }
 
@@ -142,11 +158,6 @@ module.exports = function(crowi, app) {
       }
 
       const registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
-      const isMailerSetup = mailService.isMailerSetup ?? false;
-
-      if (!isMailerSetup && registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-        return res.apiv3Err(['message.email_settings_is_not_setup'], 403);
-      }
 
       User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
         if (err) {

+ 3 - 1
apps/app/src/server/service/file-uploader/gcs.js

@@ -201,7 +201,9 @@ module.exports = function(crowi) {
 
     const gcs = getGcsInstance();
     const bucket = gcs.bucket(getGcsBucket());
-    const [files] = await bucket.getFiles();
+    const [files] = await bucket.getFiles({
+      prefix: configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+    });
 
     return files.map(({ name, metadata: { size } }) => {
       return { name, size };

+ 19 - 8
apps/app/src/server/service/in-app-notification.ts

@@ -6,7 +6,8 @@ import { Types } from 'mongoose';
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import { ActivityDocument } from '~/server/models/activity';
 import {
   InAppNotification,
@@ -17,7 +18,6 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../crowi';
-import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
@@ -51,11 +51,13 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
+    // TODO: do not use any type
+    // https://redmine.weseek.co.jp/issues/120632
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: any, users?: Ref<IUser>[]) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
+          await this.createInAppNotification(activity, target, users);
         }
       }
       catch (err) {
@@ -199,9 +201,18 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
+  // TODO: do not use any type
+  // https://redmine.weseek.co.jp/issues/120632
+  createInAppNotification = async function(activity: ActivityDocument, target, users?: Ref<IUser>[]): Promise<void> {
+    if (activity.action === SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST) {
+      const snapshot = userSerializers.stringifySnapshot(target);
+      await this.upsertByActivity(users, activity, snapshot);
+      await this.emitSocketIo(users);
+      return;
+    }
+
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-    const snapshot = stringifySnapshot(target);
+    const snapshot = pageSerializers.stringifySnapshot(target);
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
@@ -209,9 +220,9 @@ export default class InAppNotificationService {
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
       let notificationDescendantsUsers = [];
-      if (descendantsSubscribedUsers != null) {
+      if (users != null) {
         const User = this.crowi.model('User');
-        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        const descendantsUsers = users.filter(item => (item.toString() !== activity.user._id.toString()));
         notificationDescendantsUsers = await User.find({
           _id: { $in: descendantsUsers },
           status: User.STATUS_ACTIVE,

+ 5 - 6
apps/app/src/server/service/page.ts

@@ -1501,8 +1501,7 @@ class PageService {
         throw err;
       }
     }
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -1558,8 +1557,7 @@ class PageService {
       }
     }
 
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -2063,7 +2061,7 @@ class PageService {
 
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
@@ -2092,6 +2090,7 @@ class PageService {
       (async() => {
         try {
           await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
+          this.pageEvent.emit('syncDescendantsUpdate', updatedPage, user);
         }
         catch (err) {
           logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
@@ -2180,7 +2179,7 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     return updatedPage;
   }

+ 10 - 4
apps/app/src/server/service/search.ts

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
-import xss from 'xss';
+import { FilterXSS } from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPageHasId } from '~/interfaces/page';
@@ -31,7 +31,7 @@ const filterXssOptions = {
   },
 };
 
-const filterXss = new xss.FilterXSS(filterXssOptions);
+const filterXss = new FilterXSS(filterXssOptions);
 
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
@@ -140,8 +140,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('revert', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('delete', (targetPage, deletedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(deletedPage, user);
+    });
+    pageEvent.on('revert', (targetPage, revertedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(revertedPage, user);
+    });
     pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));

+ 13 - 2
apps/app/src/stores/in-app-notification.ts

@@ -1,7 +1,9 @@
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-import { parseSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import loggerFactory from '~/utils/logger';
 
 import { apiv3Get } from '../client/util/apiv3-client';
@@ -23,7 +25,16 @@ export const useSWRxInAppNotifications = <Data, Error>(
       const inAppNotificationPaginateResult = response.data as inAppNotificationPaginateResult;
       inAppNotificationPaginateResult.docs.forEach((doc) => {
         try {
-          doc.parsedSnapshot = parseSnapshot(doc.snapshot as string);
+          switch (doc.targetModel) {
+            case SupportedTargetModel.MODEL_PAGE:
+              doc.parsedSnapshot = pageSerializers.parseSnapshot(doc.snapshot);
+              break;
+            case SupportedTargetModel.MODEL_USER:
+              doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
+              break;
+            default:
+              throw new Error(`No serializer found for targetModel: ${doc.targetModel}`);
+          }
         }
         catch (err) {
           logger.warn('Failed to parse snapshot', err);

+ 2 - 4
apps/app/test/integration/service/page.test.js

@@ -666,8 +666,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, resultPage, testUser2);
     });
 
     test('delete page with isRecursively', async() => {
@@ -686,8 +685,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, resultPage, testUser2);
     });
 
 

+ 1 - 1
package.json

@@ -90,8 +90,8 @@
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
-    "tsconfig-paths": "^3.9.0",
     "ts-node-dev": "^2.0.0",
+    "tsconfig-paths": "^3.9.0",
     "typescript": "~4.9",
     "unplugin-swc": "^1.3.2",
     "vite": "^4.2.2",

+ 1 - 1
packages/presentation/package.json

@@ -21,7 +21,7 @@
     "@growi/core": "^6.1.0-RC.0"
   },
   "devDependencies": {
-    "@marp-team/marp-core": "^3.4.2",
+    "@marp-team/marp-core": "^3.6.0",
     "@types/reveal.js": "^4.4.1",
     "eslint-plugin-regex": "^1.8.0",
     "reveal.js": "^4.4.0"

+ 14 - 0
tools/replacer/.eslintrc.cjs

@@ -0,0 +1,14 @@
+module.exports = {
+  env: { browser: true, es2020: true },
+  extends: [
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:react-hooks/recommended',
+  ],
+  parser: '@typescript-eslint/parser',
+  parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
+  plugins: ['react-refresh'],
+  rules: {
+    'react-refresh/only-export-components': 'warn',
+  },
+}

+ 24 - 0
tools/replacer/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 13 - 0
tools/replacer/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Vite + React + TS</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 28 - 0
tools/replacer/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "replacer",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build",
+    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "devDependencies": {
+    "@types/react": "^18.0.28",
+    "@types/react-dom": "^18.0.11",
+    "@typescript-eslint/eslint-plugin": "^5.57.1",
+    "@typescript-eslint/parser": "^5.57.1",
+    "@vitejs/plugin-react-swc": "^3.0.0",
+    "eslint": "^8.38.0",
+    "eslint-plugin-react-hooks": "^4.6.0",
+    "eslint-plugin-react-refresh": "^0.3.4",
+    "typescript": "^5.0.2",
+    "vite": "^4.3.0"
+  }
+}

+ 49 - 0
tools/replacer/src/App.css

@@ -0,0 +1,49 @@
+#root {
+  max-width: 1280px;
+  padding: 2rem;
+  margin: 0 auto;
+  text-align: center;
+}
+
+.logo {
+  height: 6em;
+  padding: 1.5em;
+  will-change: filter;
+  transition: filter 300ms;
+}
+
+.logo:hover {
+  filter: drop-shadow(0 0 2em #646cffaa);
+}
+
+.logo.react:hover {
+  filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  a:nth-of-type(2) .logo {
+    animation: logo-spin infinite 20s linear;
+  }
+}
+
+.card {
+  padding: 2em;
+}
+
+.card textarea {
+  width: 480px;
+}
+
+
+.read-the-docs {
+  color: #888;
+}

+ 47 - 0
tools/replacer/src/App.tsx

@@ -0,0 +1,47 @@
+import { ChangeEventHandler, useState } from 'react'
+import './App.css'
+
+
+
+function replaceImport(str: string): string {
+  const regex = /import {[\s\n]*([^}]+)[\s\n]*} from 'reactstrap';/;
+
+  return str.replace(regex, (_match, group: string) => {
+    const modules = group
+      .split(',')
+      .map(mod => mod.trim())
+      .filter(mod => mod.length > 0)
+
+    return modules.map((mod) => {
+      return `import ${mod} from 'reactstrap/es/${mod}';`
+    }).join('\n')
+  });
+}
+
+function App() {
+
+  const [output, setOutput] = useState('');
+
+  const changeHandler: ChangeEventHandler<HTMLTextAreaElement> = (e): void => {
+    const { value } = e.target;
+
+    const replacedValue = replaceImport(value);
+
+    setOutput(replacedValue);
+  }
+  return (
+    <>
+      <h1>Input</h1>
+      <div className="card">
+        <textarea rows={5} onChange={changeHandler} />
+      </div>
+
+      <h1>Output</h1>
+      <div className="card">
+        <textarea rows={5} value={output} />
+      </div>
+    </>
+  )
+}
+
+export default App

+ 69 - 0
tools/replacer/src/index.css

@@ -0,0 +1,69 @@
+:root {
+  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+
+  color-scheme: light dark;
+  color: rgba(255, 255, 255, 0.87);
+  background-color: #242424;
+
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-text-size-adjust: 100%;
+}
+
+a {
+  font-weight: 500;
+  color: #646cff;
+  text-decoration: inherit;
+}
+a:hover {
+  color: #535bf2;
+}
+
+body {
+  margin: 0;
+  display: flex;
+  place-items: center;
+  min-width: 320px;
+  min-height: 100vh;
+}
+
+h1 {
+  font-size: 3.2em;
+  line-height: 1.1;
+}
+
+button {
+  border-radius: 8px;
+  border: 1px solid transparent;
+  padding: 0.6em 1.2em;
+  font-size: 1em;
+  font-weight: 500;
+  font-family: inherit;
+  background-color: #1a1a1a;
+  cursor: pointer;
+  transition: border-color 0.25s;
+}
+button:hover {
+  border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+  outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+  :root {
+    color: #213547;
+    background-color: #ffffff;
+  }
+  a:hover {
+    color: #747bff;
+  }
+  button {
+    background-color: #f9f9f9;
+  }
+}

+ 10 - 0
tools/replacer/src/main.tsx

@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+)

+ 1 - 0
tools/replacer/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 24 - 0
tools/replacer/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "lib": ["DOM", "DOM.Iterable", "ESNext"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 10 - 0
tools/replacer/tsconfig.node.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 7 - 0
tools/replacer/vite.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react-swc'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [react()],
+})

Разница между файлами не показана из-за своего большого размера
+ 35 - 670
yarn.lock


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