Przeglądaj źródła

Merge branch 'master' into 144136-145716-scroll-to-header-when-start-to-edit

reiji-h 1 rok temu
rodzic
commit
c636293e3e
67 zmienionych plików z 654 dodań i 857 usunięć
  1. 0 5
      .changeset/tasty-baboons-burn.md
  2. 15 7
      .github/workflows/reusable-app-prod.yml
  3. 1 1
      apps/app/package.json
  4. 22 24
      apps/app/playwright.config.ts
  5. 30 0
      apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts
  6. 50 0
      apps/app/playwright/20-basic-features/click-page-icons.spec.ts
  7. 49 0
      apps/app/playwright/20-basic-features/comments.spec.ts
  8. 47 0
      apps/app/playwright/20-basic-features/sticky-features.spec.ts
  9. 45 0
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  10. 14 0
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  11. 37 0
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  12. 5 20
      apps/app/playwright/auth.setup.ts
  13. 21 0
      apps/app/playwright/utils/CollapseSidebar.ts
  14. 24 0
      apps/app/playwright/utils/Login.ts
  15. 2 0
      apps/app/playwright/utils/index.ts
  16. 1 2
      apps/app/public/static/locales/en_US/translation.json
  17. 1 2
      apps/app/public/static/locales/fr_FR/translation.json
  18. 1 2
      apps/app/public/static/locales/ja_JP/translation.json
  19. 1 2
      apps/app/public/static/locales/zh_CN/translation.json
  20. 15 0
      apps/app/src/client/util/t-with-opt.ts
  21. 5 3
      apps/app/src/components/Common/PageViewLayout.tsx
  22. 1 1
      apps/app/src/components/DescendantsPageListModal.tsx
  23. 13 2
      apps/app/src/components/InstallerForm.tsx
  24. 4 2
      apps/app/src/components/Layout/RawLayout.tsx
  25. 17 23
      apps/app/src/components/LoginForm/LoginForm.tsx
  26. 1 0
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  27. 1 1
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  28. 1 1
      apps/app/src/components/PageComment.tsx
  29. 2 2
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  30. 3 1
      apps/app/src/components/SearchPage/SearchPageBase.module.scss
  31. 1 1
      apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  32. 1 1
      apps/app/src/interfaces/errors/external-account-login-error.ts
  33. 13 3
      apps/app/src/pages/installer.page.tsx
  34. 9 4
      apps/app/src/pages/login/index.page.tsx
  35. 4 6
      apps/app/src/server/middlewares/login-form-validator.ts
  36. 6 6
      apps/app/src/server/middlewares/register-form-validator.ts
  37. 3 2
      apps/app/src/server/routes/apiv3/index.js
  38. 4 1
      apps/app/src/server/routes/apiv3/installer.ts
  39. 3 5
      apps/app/src/server/routes/login-passport.js
  40. 13 6
      apps/app/src/stores/alert.tsx
  41. 32 28
      apps/app/src/stores/modal.tsx
  42. 19 0
      apps/app/src/styles/_marker.scss
  43. 0 7
      apps/app/src/styles/_variables.scss
  44. 1 1
      apps/app/src/styles/style-app.scss
  45. 0 95
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts
  46. 0 176
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts
  47. 0 140
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts
  48. 0 93
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts
  49. 0 73
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  50. 0 30
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts
  51. 0 71
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  52. 1 0
      apps/slackbot-proxy/tsconfig.json
  53. 34 1
      packages/editor/src/client/components/CodeMirrorEditorReadOnly.tsx
  54. 6 0
      packages/pluginkit/CHANGELOG.md
  55. 1 1
      packages/pluginkit/package.json
  56. 3 0
      packages/preset-themes/src/styles/antarctic.scss
  57. 3 0
      packages/preset-themes/src/styles/blackboard.scss
  58. 3 0
      packages/preset-themes/src/styles/classic.scss
  59. 1 0
      packages/preset-themes/src/styles/default.scss
  60. 6 0
      packages/preset-themes/src/styles/fire-red.scss
  61. 3 0
      packages/preset-themes/src/styles/future.scss
  62. 2 0
      packages/preset-themes/src/styles/halloween.scss
  63. 6 0
      packages/preset-themes/src/styles/hufflepuff.scss
  64. 6 0
      packages/preset-themes/src/styles/jade-green.scss
  65. 3 0
      packages/preset-themes/src/styles/nature.scss
  66. 3 0
      packages/preset-themes/src/styles/wood.scss
  67. 35 5
      yarn.lock

+ 0 - 5
.changeset/tasty-baboons-burn.md

@@ -1,5 +0,0 @@
----
-'@growi/pluginkit': patch
----
-
-Update tsconfig.json module setting

+ 15 - 7
.github/workflows/reusable-app-prod.yml

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['20', '21', '22', '23', '30', '50']
+        spec-group: ['20', '21', '23', '30', '50']
 
     services:
       mongodb:
@@ -425,12 +425,6 @@ jobs:
       run: |
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
-    # - name: Copy dotenv file for automatic installation with allowing guest mode
-    #   if: ${{ matrix.spec-group == '21' }}
-    #   working-directory: ./apps/app
-    #   run: |
-    #     cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
-
     - name: Playwright Run
       working-directory: ./apps/app
       run: |
@@ -440,6 +434,20 @@ jobs:
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
+    - name: Copy dotenv file for automatic installation with allowing guest mode
+      working-directory: ./apps/app
+      run: |
+        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
+    - name: Playwright Run (--project=${browser}/guest-mode)
+      working-directory: ./apps/app
+      run: |
+        yarn playwright test --project=${{ matrix.browser }}/guest-mode --shard=${{ matrix.shard }}
+      env:
+        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()

+ 1 - 1
apps/app/package.json

@@ -205,7 +205,7 @@
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
-    "ws": "^8.3.0",
+    "ws": "^8.17.1",
     "xss": "^1.0.14",
     "y-mongodb-provider": "^0.1.10",
     "y-socket.io": "^1.1.3",

+ 22 - 24
apps/app/playwright.config.ts

@@ -1,10 +1,28 @@
 import fs from 'node:fs';
 import path from 'node:path';
 
-import { defineConfig, devices } from '@playwright/test';
+import { defineConfig, devices, type Project } from '@playwright/test';
 
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');
 
+// Use prepared auth state.
+const storageState = fs.existsSync(authFile) ? authFile : undefined;
+
+const supportedBrowsers = ['chromium', 'firefox', 'webkit'] as const;
+
+const projects: Array<Project> = supportedBrowsers.map(browser => ({
+  name: browser,
+  use: { ...devices[`Desktop ${browser}`], storageState },
+  testIgnore: /(10-installer|21-basic-features-for-guest)\/.*\.spec\.ts/,
+  dependencies: ['setup', 'auth'],
+}));
+
+const projectsForGuestMode: Array<Project> = supportedBrowsers.map(browser => ({
+  name: `${browser}/guest-mode`,
+  use: { ...devices[`Desktop ${browser}`] }, // Do not use storageState
+  testMatch: /21-basic-features-for-guest\/.*\.spec\.ts/,
+}));
+
 /**
  * Read environment variables from file.
  * https://github.com/motdotla/dotenv
@@ -49,9 +67,6 @@ export default defineConfig({
     trace: 'on-first-retry',
 
     viewport: { width: 1400, height: 1024 },
-
-    // Use prepared auth state.
-    storageState: fs.existsSync(authFile) ? authFile : undefined,
   },
 
   /* Configure projects for major browsers */
@@ -62,31 +77,14 @@ export default defineConfig({
 
     {
       name: 'chromium/installer',
-      use: { ...devices['Desktop Chrome'] },
+      use: { ...devices['Desktop Chrome'], storageState },
       testMatch: /10-installer\/.*\.spec\.ts/,
       dependencies: ['setup'],
     },
 
-    {
-      name: 'chromium',
-      use: { ...devices['Desktop Chrome'] },
-      testIgnore: /10-installer\/.*\.spec\.ts/,
-      dependencies: ['setup', 'auth'],
-    },
-
-    {
-      name: 'firefox',
-      use: { ...devices['Desktop Firefox'] },
-      testIgnore: /10-installer\/.*\.spec\.ts/,
-      dependencies: ['setup', 'auth'],
-    },
+    ...projects,
 
-    {
-      name: 'webkit',
-      use: { ...devices['Desktop Safari'] },
-      testIgnore: /10-installer\/.*\.spec\.ts/,
-      dependencies: ['setup', 'auth'],
-    },
+    ...projectsForGuestMode,
 
     /* Test against mobile viewports. */
     // {

+ 30 - 0
apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts

@@ -0,0 +1,30 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const openPageAccessoriesModal = async(page: Page): Promise<void> => {
+  await page.goto('/');
+  await page.getByTestId('pageListButton').click();
+  await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
+};
+
+test('Page list modal is successfully opened', async({ page }) => {
+  await openPageAccessoriesModal(page);
+  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText('You cannot see this page');
+});
+
+test('Successfully open PageItemControl', async({ page }) => {
+  await openPageAccessoriesModal(page);
+  await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+  await expect(page.locator('.dropdown-menu.show')).toBeVisible();
+});
+
+test('Successfully close modal', async({ page }) => {
+  await openPageAccessoriesModal(page);
+  await page.locator('.btn-close').click();
+  await expect(page.getByTestId('descendants-page-list-modal')).not.toBeVisible();
+});
+
+test('Timeline list successfully openend', async({ page }) => {
+  await openPageAccessoriesModal(page);
+  await page.getByTestId('timeline-tab-button').click();
+  await expect(page.locator('.card-timeline').first()).toBeVisible();
+});

+ 50 - 0
apps/app/playwright/20-basic-features/click-page-icons.spec.ts

@@ -0,0 +1,50 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Click page icons', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/Sandbox');
+  });
+
+  test('Successfully Subscribe/Unsubscribe a page', async({ page }) => {
+    const subscribeButton = page.locator('.btn-subscribe');
+
+    // Subscribe
+    await subscribeButton.click();
+    await expect(subscribeButton).toHaveClass(/active/);
+
+    // Unsubscribe
+    await subscribeButton.click();
+    await expect(subscribeButton).not.toHaveClass(/active/);
+  });
+
+  test('Successfully Like/Unlike a page', async({ page }) => {
+    const likeButton = page.locator('.btn-like').first();
+
+    // Like
+    await likeButton.click();
+    await expect(likeButton).toHaveClass(/active/);
+
+    // Unlike
+    await likeButton.click();
+    await expect(likeButton).not.toHaveClass(/active/);
+  });
+
+  test('Successfully Bookmark / Unbookmark a page', async({ page }) => {
+    const bookmarkButton = page.locator('.btn-bookmark').first();
+
+    // Bookmark
+    await bookmarkButton.click();
+    await expect(bookmarkButton).toHaveClass(/active/);
+
+    // Unbookmark
+    await page.locator('.grw-bookmark-folder-menu-item').click();
+    await expect(bookmarkButton).not.toHaveClass(/active/);
+  });
+
+  test('Successfully display list of "seen by user"', async({ page }) => {
+    await page.locator('.btn-seen-user').click();
+
+    const imgCount = await page.locator('.user-list-content').locator('img').count();
+    expect(imgCount).toBe(1);
+  });
+});

+ 49 - 0
apps/app/playwright/20-basic-features/comments.spec.ts

@@ -0,0 +1,49 @@
+import { test, expect } from '@playwright/test';
+
+test('Create comment page', async({ page }) => {
+  await page.goto('/comment');
+  await page.getByTestId('editor-button').click();
+  await page.getByTestId('save-page-btn').click();
+  await expect(page.locator('.page-meta')).toBeVisible();
+});
+
+test('Successfully add comments', async({ page }) => {
+  const commentText = 'add comment';
+  await page.goto('/comment');
+
+  // Add comment
+  await page.getByTestId('page-comment-button').click();
+  await page.getByTestId('open-comment-editor-button').click();
+  await page.locator('.cm-content').fill(commentText);
+  await page.getByTestId('comment-submit-button').first().click();
+
+  await expect(page.locator('.page-comment-body')).toHaveText(commentText);
+  await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
+});
+
+test('Successfully reply comments', async({ page }) => {
+  const commentText = 'reply comment';
+  await page.goto('/comment');
+
+  // Reply comment
+  await page.getByTestId('page-comment-button').click();
+  await page.getByTestId('comment-reply-button').click();
+  await page.locator('.cm-content').fill(commentText);
+  await page.getByTestId('comment-submit-button').first().click();
+
+  await expect(page.locator('.page-comment-body').nth(1)).toHaveText(commentText);
+  await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('2');
+});
+
+// test('Successfully delete comments', async({ page }) => {
+//   await page.goto('/comment');
+
+//   await page.getByTestId('page-comment-button').click();
+//   await page.getByTestId('comment-delete-button').first().click({ force: true });
+//   await expect(page.getByTestId('page-comment-delete-modal')).toBeVisible();
+//   await page.getByTestId('delete-comment-button').click();
+
+//   await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('0');
+// });
+
+// TODO: https://redmine.weseek.co.jp/issues/139520

+ 47 - 0
apps/app/playwright/20-basic-features/sticky-features.spec.ts

@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Sticky features', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/');
+  });
+
+  test('Subnavigation displays changes on scroll down and up', async({ page }) => {
+    // Scroll down to trigger sticky effect
+    await page.evaluate(() => window.scrollTo(0, 250));
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+
+    // Scroll back to top
+    await page.evaluate(() => window.scrollTo(0, 0));
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+  });
+
+  test('Subnavigation is not displayed when move to other pages', async({ page }) => {
+    // Scroll down to trigger sticky effect
+    await page.evaluate(() => window.scrollTo(0, 250));
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+
+    // Move to /Sandbox page
+    await page.goto('/Sandbox');
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+  });
+
+  test('Able to click buttons on subnavigation switcher when sticky', async({ page }) => {
+    // Scroll down to trigger sticky effect
+    await page.evaluate(() => window.scrollTo(0, 250));
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+
+    // Click editor button
+    await page.getByTestId('editor-button').click();
+    await expect(page.locator('.layout-root')).toHaveClass(/editing/);
+  });
+
+  test('Subnavigation is sticky when on small window', async({ page }) => {
+    // Scroll down to trigger sticky effect
+    await page.evaluate(() => window.scrollTo(0, 500));
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+
+    // Set viewport to small size
+    await page.setViewportSize({ width: 600, height: 1024 });
+    await expect(page.getByTestId('grw-contextual-sub-nav').getByTestId('grw-page-editor-mode-manager')).toBeVisible();
+  });
+});

+ 45 - 0
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -0,0 +1,45 @@
+import { test, expect } from '@playwright/test';
+
+import { collapseSidebar } from '../utils';
+
+test('/Sandbox is successfully loaded', async({ page }) => {
+
+  await page.goto('/Sandbox');
+
+  // Expect a title "to contain" a substring.
+  await expect(page).toHaveTitle(/Sandbox/);
+});
+
+test('/Sandbox/math is successfully loaded', async({ page }) => {
+
+  await page.goto('/Sandbox/Math');
+
+  // Check if the math elements are visible
+  await expect(page.locator('.math').first()).toBeVisible();
+});
+
+test('Access to /me page', async({ page }) => {
+  await page.goto('/me');
+
+  // Expect to be redirected to /login when accessing /me
+  await expect(page.getByTestId('login-form')).toBeVisible();
+});
+
+test('Access to /trash page', async({ page }) => {
+  await page.goto('/trash');
+
+  // Expect the trash page specific elements to be present when accessing /trash
+  await expect(page.getByTestId('trash-page-list')).toBeVisible();
+});
+
+// TODO: Improve collapseSidebar (https://redmine.weseek.co.jp/issues/148538)
+// test('Access to /tags page', async({ page }) => {
+//   await page.goto('/tags');
+
+//   await collapseSidebar(page, false);
+//   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
+//   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
+//   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
+//   await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
+//   await expect(page.getByTestId('tags-page')).toBeVisible();
+// });

+ 14 - 0
apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts

@@ -0,0 +1,14 @@
+import { test, expect } from '@playwright/test';
+
+
+test('Sub navigation sticky changes when scrolling down and up', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  // Sticky
+  await page.evaluate(() => window.scrollTo(0, 250));
+  await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+
+  // Not sticky
+  await page.evaluate(() => window.scrollTo(0, 0));
+  await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+});

+ 37 - 0
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -0,0 +1,37 @@
+import { test, expect } from '@playwright/test';
+
+import { login } from '../utils/Login';
+
+test.describe.serial('Access to sharelink by guest', () => {
+  let createdSharelink: string | null;
+
+  test('Prepare sharelink', async({ page }) => {
+    await page.goto('/Sandbox/Bootstrap5');
+
+    // Create Sharelink
+    await page.getByTestId('open-page-item-control-btn').click();
+    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+    await page.getByTestId('btn-sharelink-toggleform').click();
+    await page.getByTestId('btn-sharelink-issue').click();
+
+    // Get ShareLink
+    createdSharelink = await page.getByTestId('share-link').textContent();
+    expect(createdSharelink).toHaveLength(24);
+  });
+
+  test('The sharelink page is successfully loaded', async({ page }) => {
+    await page.goto('/');
+
+    // Logout
+    await page.getByTestId('personal-dropdown-button').click();
+    await expect(page.getByTestId('logout-button')).toBeVisible();
+    await page.getByTestId('logout-button').click();
+    await page.waitForURL('http://localhost:3000/login');
+
+    // Access sharelink
+    await page.goto(`/share/${createdSharelink}`);
+    await expect(page.locator('.page-meta')).toBeVisible();
+
+    await login(page);
+  });
+});

+ 5 - 20
apps/app/playwright/auth.setup.ts

@@ -1,24 +1,9 @@
-import path from 'node:path';
+import { test as setup } from '@playwright/test';
 
-import { test as setup, expect } from '@playwright/test';
-
-const authFile = path.resolve(__dirname, './.auth/admin.json');
+import { login } from './utils/Login';
 
+// Commonised login process for use elsewhere
+// see: https://github.com/microsoft/playwright/issues/22114
 setup('Authenticate as the "admin" user', async({ page }) => {
-  // Perform authentication steps. Replace these actions with your own.
-  await page.goto('/admin');
-
-  const loginForm = await page.$('form#login-form');
-
-  if (loginForm != null) {
-    await page.getByLabel('Username or E-mail').fill('admin');
-    await page.getByLabel('Password').fill('adminadmin');
-    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
-  }
-
-  await page.waitForURL('/admin');
-  await expect(page).toHaveTitle(/Wiki Management Homepage/);
-
-  // End of authentication steps.
-  await page.context().storageState({ path: authFile });
+  await login(page);
 });

+ 21 - 0
apps/app/playwright/utils/CollapseSidebar.ts

@@ -0,0 +1,21 @@
+// TODO: https://redmine.weseek.co.jp/issues/148538
+import { expect, type Page } from '@playwright/test';
+
+export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<void> => {
+  const isSidebarContentsHidden = !(await page.getByTestId('grw-sidebar-contents').isVisible());
+  if (isSidebarContentsHidden === isCollapsed) {
+    return;
+  }
+
+  const collapseSidebarToggle = page.getByTestId('btn-toggle-collapse');
+  await expect(collapseSidebarToggle).toBeVisible();
+
+  await collapseSidebarToggle.click();
+
+  if (isCollapsed) {
+    await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
+  }
+  else {
+    await expect(page.locator('.grw-sidebar-dock')).toBeVisible();
+  }
+};

+ 24 - 0
apps/app/playwright/utils/Login.ts

@@ -0,0 +1,24 @@
+import path from 'node:path';
+
+import { expect, type Page } from '@playwright/test';
+
+const authFile = path.resolve(__dirname, '../.auth/admin.json');
+
+export const login = async(page: Page): Promise<void> => {
+  // Perform authentication steps. Replace these actions with your own.
+  await page.goto('/admin');
+
+  const loginForm = await page.$('form#login-form');
+
+  if (loginForm != null) {
+    await page.getByLabel('Username or E-mail').fill('admin');
+    await page.getByLabel('Password').fill('adminadmin');
+    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+  }
+
+  await page.waitForURL('/admin');
+  await expect(page).toHaveTitle(/Wiki Management Homepage/);
+
+  // End of authentication steps.
+  await page.context().storageState({ path: authFile });
+};

+ 2 - 0
apps/app/playwright/utils/index.ts

@@ -0,0 +1,2 @@
+export * from './CollapseSidebar';
+export * from './Login';

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

@@ -671,10 +671,9 @@
     "Email format is invalid": "Email format is invalid.",
     "Email field is required": "Email field is required.",
     "Password has invalid character": "Password has invalid character.",
-    "Password minimum character should be more than 8 characters": "Password minimum character should be more than 8 characters.",
+    "Password minimum character should be more than n characters": "Password minimum character should be more than {{number}} characters.",
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
-    "Password minimum character should be more than 6 characters": "Password minimum character should be more than 6 characters.",
     "user_not_found": "User not found.",
     "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },

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

@@ -665,10 +665,9 @@
     "Email format is invalid": "Format d'adresse courriel invalide.",
     "Email field is required": "Adresse courriel requise.",
     "Password has invalid character": "Le mot de passe contient des caractères invalides",
-    "Password minimum character should be more than 8 characters": "Le mot de passe doit contenir plus de 8 caractères.",
+    "Password minimum character should be more than n characters": "Le mot de passe doit contenir plus de {{number}} caractères.",
     "Password field is required": "Mot de passe requis.",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
-    "Password minimum character should be more than 6 characters": "Le mot de passe doit contenir au moins 6 caractères.",
     "user_not_found": "Utilisateur introuvable.",
     "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },

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

@@ -704,10 +704,9 @@
     "Email format is invalid": "メールアドレスのフォーマットが無効です",
     "Email field is required": "メールアドレスは必須項目です",
     "Password has invalid character": "パスワードに無効な文字があります",
-    "Password minimum character should be more than 8 characters": "パスワードの最小文字数は8文字以上です",
+    "Password minimum character should be more than n characters": "パスワードの最小文字数は{{number}}文字以上です",
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
-    "Password minimum character should be more than 6 characters": "パスワードの最小文字数は6文字以上です",
     "user_not_found": "ユーザーが見つかりません",
     "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
   },

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

@@ -674,10 +674,9 @@
     "Email format is invalid": "电子邮件的格式是无效的",
     "Email field is required": "电子邮件字段是必需的",
     "Password has invalid character": "密码有无效字符",
-    "Password minimum character should be more than 8 characters": "密码最小字符应超过8个字符",
+    "Password minimum character should be more than n characters": "密码最小字符应超过{{number}}个字符",
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
-    "Password minimum character should be more than 6 characters": "密码最小字符应超过6个字符",
     "user_not_found": "未找到用户",
     "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },

+ 15 - 0
apps/app/src/client/util/t-with-opt.ts

@@ -0,0 +1,15 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+export const useTWithOpt = (): (key: string, opt?: any) => string => {
+
+  const { t } = useTranslation();
+
+  return useCallback((key, opt) => {
+    if (typeof opt === 'object') {
+      return t(key, opt).toString();
+    }
+    return t(key);
+  }, [t]);
+};

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

@@ -23,12 +23,12 @@ export const PageViewLayout = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar position-relative z-0`}>
+      <div className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar`}>
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { sideContents != null
             ? (
-              <div className="flex-expand-horiz gap-3">
+              <div className="flex-expand-horiz gap-3 z-0">
                 <div className="flex-expand-vert flex-basis-0 mw-0">
                   {children}
                 </div>
@@ -40,7 +40,9 @@ export const PageViewLayout = (props: Props): JSX.Element => {
               </div>
             )
             : (
-              <>{children}</>
+              <div className="z-0">
+                {children}
+              </div>
             )
           }
         </div>

+ 1 - 1
apps/app/src/components/DescendantsPageListModal.tsx

@@ -55,7 +55,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: () => <span className="material-symbols-outlined">timeline</span>,
+        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
         Content: () => {
           if (status == null || !status.isOpened) {
             return <></>;

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

@@ -9,19 +9,29 @@ import { useRouter } from 'next/router';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { useTWithOpt } from '~/client/util/t-with-opt';
 import { toastError } from '~/client/util/toastr';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 
+
 import styles from './InstallerForm.module.scss';
 
+
 const moduleClass = styles['installer-form'] ?? '';
 
+type Props = {
+  minPasswordLength: number,
+}
 
-const InstallerForm = memo((): JSX.Element => {
+const InstallerForm = memo((props: Props): JSX.Element => {
   const { t, i18n } = useTranslation();
 
+  const { minPasswordLength } = props;
+
   const router = useRouter();
 
+  const tWithOpt = useTWithOpt();
+
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isValidUserName, setValidUserName] = useState(true);
@@ -113,7 +123,7 @@ const InstallerForm = memo((): JSX.Element => {
             <p className="alert alert-danger text-center">
               {registerErrors.map(err => (
                 <span>
-                  {t(err.message)}<br />
+                  {tWithOpt(err.message, err.args)}<br />
                 </span>
               ))}
             </p>
@@ -218,6 +228,7 @@ const InstallerForm = memo((): JSX.Element => {
               <span className="material-symbols-outlined" aria-hidden>lock</span>
             </label>
             <input
+              minLength={minPasswordLength}
               id="tiPassword"
               type="password"
               className="form-control rounded"

+ 4 - 2
apps/app/src/components/Layout/RawLayout.tsx

@@ -2,14 +2,13 @@ import type { ReactNode } from 'react';
 import React, { useState } from 'react';
 
 import type { ColorScheme } from '@growi/core';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
-import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 import { useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
-
 import styles from './RawLayout.module.scss';
 
 const toastContainerClass = styles['grw-toast-container'] ?? '';
@@ -17,6 +16,9 @@ const toastContainerClass = styles['grw-toast-container'] ?? '';
 const logger = loggerFactory('growi:cli:RawLayout');
 
 
+const ToastContainer = dynamic(() => import('react-toastify').then(mod => mod.ToastContainer), { ssr: false });
+
+
 type Props = {
   className?: string,
   children?: ReactNode,

+ 17 - 23
apps/app/src/components/LoginForm/LoginForm.tsx

@@ -9,6 +9,7 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { useTWithOpt } from '~/client/util/t-with-opt';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
@@ -21,6 +22,7 @@ import { ExternalAuthButton } from './ExternalAuthButton';
 
 import styles from './LoginForm.module.scss';
 
+
 const moduleClass = styles['login-form'];
 
 
@@ -38,6 +40,7 @@ type LoginFormProps = {
   enabledExternalAuthType?: IExternalAuthProviderType[],
   isMailerSetup?: boolean,
   externalAccountLoginError?: IExternalAccountLoginError,
+  minPasswordLength: number,
 }
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
@@ -46,8 +49,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   const {
     isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
-    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, enabledExternalAuthType,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, enabledExternalAuthType, minPasswordLength,
   } = props;
+
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
   const isSomeExternalAuthEnabled = enabledExternalAuthType != null && enabledExternalAuthType.length > 0;
 
@@ -71,6 +75,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   const isRegistrationEnabled = isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
 
+  const tWithOpt = useTWithOpt();
+
   useEffect(() => {
     const { hash } = window.location;
     if (hash === '#register') {
@@ -78,13 +84,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     }
   }, []);
 
-  const tWithOpt = useCallback((key: string, opt?: any) => {
-    if (typeof opt === 'object') {
-      return t(key, opt).toString();
-    }
-    return t(key);
-  }, [t]);
-
   const resetLoginErrors = useCallback(() => {
     if (loginErrors.length === 0) return;
     setLoginErrors([]);
@@ -172,9 +171,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     // Generate login error elements using <ul>, <li>
 
-    const loginErrorElement = props.externalAccountLoginError != null
-      ? generateSafelySetErrors([...loginErrorList, props.externalAccountLoginError])
-      : generateSafelySetErrors(loginErrorList);
+    const loginErrorElement = (loginErrorList ?? []).length > 0
+    // prioritize loginErrorList because the list should contains new error
+      ? generateSafelySetErrors(loginErrorList)
+      : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
 
     return (
       <>
@@ -253,15 +253,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       </>
     );
   }, [
-    props,
-    separateErrorsBasedOnErrorCode,
-    loginErrors,
-    generateDangerouslySetErrors,
-    generateSafelySetErrors,
-    isLdapSetupFailed,
-    t,
-    handleLoginWithLocalSubmit,
-    isLoading,
+    props, separateErrorsBasedOnErrorCode, loginErrors, generateDangerouslySetErrors, generateSafelySetErrors,
+    isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
   ]);
 
 
@@ -360,7 +353,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <p className="alert alert-danger">
               {registerErrors.map(err => (
                 <span>
-                  {t(err.message)}<br />
+                  {tWithOpt(err.message, err.args)}<br />
                 </span>
               ))}
             </p>
@@ -461,6 +454,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                   placeholder={t('Password')}
                   name="password"
                   required
+                  minLength={minPasswordLength}
                 />
               </div>
             </div>
@@ -501,8 +495,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       </React.Fragment>
     );
   }, [
-    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit, isLoading,
+    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration, emailForRegistrationOrder,
+    props.username, props.name, props.email, registrationWhitelist, minPasswordLength, isLoading, switchForm, tWithOpt, handleRegisterFormSubmit,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {

+ 1 - 0
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -108,6 +108,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
         role="group"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
+        data-testid="grw-page-editor-mode-manager"
       >
         {(isDeviceLargerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButton

+ 1 - 1
apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -25,7 +25,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
   return (
     <tr key={shareLinkId}>
       <td className="d-flex justify-content-between align-items-center">
-        <span>{shareLinkId}</span>
+        <span data-testid="share-link">{shareLinkId}</span>
 
         { isRelatedPageExists && (
           <CopyDropdown

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

@@ -179,7 +179,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                       <NotAvailableForReadOnlyUser>
                         <button
                           type="button"
-                          id="comment-reply-button"
+                          data-testid="comment-reply-button"
                           className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
                           onClick={() => onReplyButtonClickHandler(comment._id)}
                         >

+ 2 - 2
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -75,7 +75,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
       <>
         <span className="text-danger">{errorMessage}</span>&nbsp;
         <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
-        <Button color="danger" onClick={confirmToDelete}>
+        <Button data-testid="delete-comment-button" color="danger" onClick={confirmToDelete}>
           <span className="material-symbols-outlined">delete_forever</span>
           {t('Delete')}
         </Button>
@@ -84,7 +84,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
   };
 
   return (
-    <Modal isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+    <Modal data-testid="page-comment-delete-modal" isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
       <ModalHeader tag="h4" toggle={cancelToDelete} className="text-danger">
         {headerContent()}
       </ModalHeader>

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

@@ -9,6 +9,8 @@
 
 .search-result-content :global  {
   .highlighted-keyword {
-    background:linear-gradient(transparent 40%, #FCF0C0 40%);
+    background:linear-gradient(
+      transparent 40%,
+      var(--grw-marker-bg, var(--grw-marker-bg-yellow)) 40%);
   }
 }

+ 1 - 1
apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -108,7 +108,7 @@ export const PersonalDropdown = (): JSX.Element => {
             </span>
           </DropdownItem>
 
-          <DropdownItem onClick={logoutHandler} className={`my-1 ${styles['personal-dropdown-item']}`}>
+          <DropdownItem data-testid="logout-button" onClick={logoutHandler} className={`my-1 ${styles['personal-dropdown-item']}`}>
             <span className="d-flex align-items-center">
               <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">logout</span>
               <span className="item-text">{t('Sign out')}</span>

+ 1 - 1
apps/app/src/interfaces/errors/external-account-login-error.ts

@@ -1,4 +1,4 @@
-import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
+import type { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
 
 export type IExternalAccountLoginError = ExternalAccountLoginError;
 

+ 13 - 3
apps/app/src/pages/installer.page.tsx

@@ -10,6 +10,7 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 
 import InstallerForm from '../components/InstallerForm';
 import {
@@ -31,7 +32,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 type Props = CommonProps & {
-
+  minPasswordLength: number,
   pageWithMetaStr: string,
 };
 
@@ -43,7 +44,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
     return {
       user_infomation: {
         Icon: () => <span className="material-symbols-outlined me-2">person</span>,
-        Content: InstallerForm,
+        Content: () => <InstallerForm minPasswordLength={props.minPasswordLength} />,
         i18n: t('installer.tab'),
       },
       external_accounts: {
@@ -53,7 +54,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
         i18n: tCommons('g2g_data_transfer.tab'),
       },
     };
-  }, [t, tCommons]);
+  }, [props.minPasswordLength, t, tCommons]);
 
   // commons
   useAppTitle(props.appTitle);
@@ -76,6 +77,14 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   );
 };
 
+async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager } = crowi;
+
+  props.minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
+}
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const result = await getServerSideCommonProps(context);
 
@@ -88,6 +97,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
 
   await injectNextI18NextConfigurations(context, props, ['translation']);
+  injectServerConfigurations(context, props);
 
   return {
     props,

+ 9 - 4
apps/app/src/pages/login/index.page.tsx

@@ -36,6 +36,7 @@ type Props = CommonProps & {
   isPasswordResetEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
   externalAccountLoginError?: IExternalAccountLoginError,
+  minPasswordLength: number,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -66,6 +67,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isMailerSetup={props.isMailerSetup}
         registrationMode={props.registrationMode}
         externalAccountLoginError={props.externalAccountLoginError}
+        minPasswordLength={props.minPasswordLength}
       />
     </NoLoginLayout>
   );
@@ -117,6 +119,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
   props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
   props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
+  props.minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
@@ -130,10 +133,12 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   const props: Props = result.props as Props;
 
-  if (context.query.externalAccountLoginError != null) {
-    const externalAccountLoginError = context.query.externalAccountLoginError;
-    if (isExternalAccountLoginError(externalAccountLoginError)) {
-      props.externalAccountLoginError = { ...externalAccountLoginError as IExternalAccountLoginError };
+  const externalAccountLoginError = (context.req as CrowiRequest).session.externalAccountLoginError;
+  if (externalAccountLoginError != null) {
+    delete (context.req as CrowiRequest).session.externalAccountLoginError;
+    const parsedError = JSON.parse(externalAccountLoginError);
+    if (isExternalAccountLoginError(parsedError)) {
+      props.externalAccountLoginError = { ...parsedError as IExternalAccountLoginError };
     }
   }
 

+ 4 - 6
apps/app/src/server/middlewares/login-form-validator.ts

@@ -1,7 +1,7 @@
-import { body, validationResult } from 'express-validator';
-
+import { body, validationResult, type ValidationChain } from 'express-validator';
 // form rules
-export const loginRules = () => {
+export const loginRules = (): ValidationChain[] => {
+
   return [
     body('loginForm.username')
       .matches(/^[\da-zA-Z\-_.+@]+$/)
@@ -12,8 +12,6 @@ export const loginRules = () => {
     body('loginForm.password')
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('message.Password has invalid character')
-      .isLength({ min: 6 })
-      .withMessage('message.Password minimum character should be more than 6 characters')
       .not()
       .isEmpty()
       .withMessage('message.Password field is required'),
@@ -21,7 +19,7 @@ export const loginRules = () => {
 };
 
 // validation action
-export const loginValidation = (req, res, next) => {
+export const loginValidation = (req, res, next): ValidationChain[] => {
   const form = req.body;
 
   const errors = validationResult(req);

+ 6 - 6
apps/app/src/server/middlewares/register-form-validator.ts

@@ -1,8 +1,8 @@
-import { body, validationResult } from 'express-validator';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { body, validationResult, type ValidationChain } from 'express-validator';
 
-const PASSOWRD_MINIMUM_NUMBER = 8;
 // form rules
-export const registerRules = () => {
+export const registerRules = (minPasswordLength: number): ValidationChain[] => {
   return [
     body('registerForm.username')
       .matches(/^[\da-zA-Z\-_.]+$/)
@@ -19,8 +19,8 @@ export const registerRules = () => {
     body('registerForm.password')
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('message.Password has invalid character')
-      .isLength({ min: PASSOWRD_MINIMUM_NUMBER })
-      .withMessage('message.Password minimum character should be more than 8 characters')
+      .isLength({ min: minPasswordLength })
+      .withMessage(new ErrorV3('message.Password minimum character should be more than n characters', undefined, undefined, { number: minPasswordLength }))
       .not()
       .isEmpty()
       .withMessage('message.Password field is required'),
@@ -29,7 +29,7 @@ export const registerRules = () => {
 };
 
 // validation action
-export const registerValidation = (req, res, next) => {
+export const registerValidation = (req, res, next): ValidationChain[] => {
   const form = req.body;
 
   const errors = validationResult(req);

+ 3 - 2
apps/app/src/server/routes/apiv3/index.js

@@ -22,6 +22,7 @@ const routerForAuth = express.Router();
 
 module.exports = (crowi, app) => {
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
+  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -62,9 +63,9 @@ module.exports = (crowi, app) => {
   routerForAuth.use('/logout', require('./logout')(crowi));
 
   routerForAuth.post('/register',
-    applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
+    applicationInstalled, registerFormValidator.registerRules(minPasswordLength), registerFormValidator.registerValidation, addActivity, login.register);
 
-  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(),
+  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(minPasswordLength),
     userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
   // installer

+ 4 - 1
apps/app/src/server/routes/apiv3/installer.ts

@@ -3,6 +3,7 @@ import type { Request, Router } from 'express';
 import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -26,8 +27,10 @@ module.exports = (crowi: Crowi): Router => {
 
   const router = express.Router();
 
+  const minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
+
   // eslint-disable-next-line max-len
-  router.post('/', registerRules(), registerValidation, addActivity, async(req: FormRequest, res: ApiV3Response) => {
+  router.post('/', registerRules(minPasswordLength), registerValidation, addActivity, async(req: FormRequest, res: ApiV3Response) => {
     const appService = crowi.appService;
     if (appService == null) {
       return res.apiv3Err(new ErrorV3('GROWI cannot be installed due to an internal error', 'app_service_not_setup'), 500);

+ 3 - 5
apps/app/src/server/routes/login-passport.js

@@ -1,4 +1,3 @@
-
 import { ErrorV3 } from '@growi/core/dist/models';
 import next from 'next';
 
@@ -124,7 +123,6 @@ module.exports = function(crowi, app) {
 
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     activityEvent.emit('update', res.locals.activity._id, parameters);
-
     return res.apiv3Err(error);
   };
 
@@ -136,9 +134,9 @@ module.exports = function(crowi, app) {
     };
     await crowi.activityService.createActivity(parameters);
 
-    const { nextApp } = crowi;
     req.crowi = crowi;
-    nextApp.render(req, res, '/login', { externalAccountLoginError: error });
+    req.session.externalAccountLoginError = JSON.stringify(error);
+    res.redirect('/login');
     return;
   };
 
@@ -504,7 +502,7 @@ module.exports = function(crowi, app) {
     passport.authenticate('saml')(req, res);
   };
 
-  const loginPassportSamlCallback = async(req, res) => {
+  const loginPassportSamlCallback = async(req, res, next) => {
     const providerId = 'saml';
     const strategyName = 'saml';
     const attrMapId = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapId');

+ 13 - 6
apps/app/src/stores/alert.tsx

@@ -1,3 +1,5 @@
+import { useCallback } from 'react';
+
 import { useSWRStatic } from '@growi/core/dist/swr';
 import type { SWRResponse } from 'swr';
 
@@ -26,14 +28,19 @@ type PageStatusAlertUtils = {
 export const usePageStatusAlert = (): SWRResponse<PageStatusAlertStatus, Error> & PageStatusAlertUtils => {
   const initialData: PageStatusAlertStatus = { isOpen: false };
   const swrResponse = useSWRStatic<PageStatusAlertStatus, Error>('pageStatusAlert', undefined, { fallbackData: initialData });
+  const { mutate } = swrResponse;
+
+  const open = useCallback(({ ...options }) => {
+    mutate({ isOpen: true, ...options });
+  }, [mutate]);
+
+  const close = useCallback(() => {
+    mutate({ isOpen: false });
+  }, [mutate]);
 
   return {
     ...swrResponse,
-    open({ ...options }) {
-      swrResponse.mutate({ isOpen: true, ...options });
-    },
-    close() {
-      swrResponse.mutate({ isOpen: false });
-    },
+    open,
+    close,
   };
 };

+ 32 - 28
apps/app/src/stores/modal.tsx

@@ -13,8 +13,6 @@ import type {
 } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
-import { useStaticSWR } from './use-static-swr';
-
 const logger = loggerFactory('growi:stores:modal');
 
 /*
@@ -32,7 +30,7 @@ type CreateModalStatusUtils = {
 
 export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
   const initialData: CreateModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<CreateModalStatus, Error>('pageCreateModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<CreateModalStatus, Error>('pageCreateModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -99,7 +97,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
     isOpened: false,
     pages: [],
   };
-  const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -140,7 +138,7 @@ export const useEmptyTrashModal = (status?: EmptyTrashModalStatus): SWRResponse<
     isOpened: false,
     pages: [],
   };
-  const swrResponse = useStaticSWR<EmptyTrashModalStatus, Error>('emptyTrashModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<EmptyTrashModalStatus, Error>('emptyTrashModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -182,7 +180,7 @@ type DuplicateModalStatusUtils = {
 
 export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
   const initialData: DuplicateModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -218,7 +216,7 @@ type RenameModalStatusUtils = {
 
 export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
   const initialData: RenameModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -264,7 +262,7 @@ export const usePutBackPageModal = (status?: PutBackPageModalStatus): SWRRespons
     isOpened: false,
     page: { pageId: '', path: '' },
   }), []);
-  const swrResponse = useStaticSWR<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -296,7 +294,7 @@ export const usePagePresentationModal = (
   const initialData: PresentationModalStatus = {
     isOpened: false,
   };
-  const swrResponse = useStaticSWR<PresentationModalStatus, Error>('presentationModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<PresentationModalStatus, Error>('presentationModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -332,7 +330,7 @@ export const usePrivateLegacyPagesMigrationModal = (
     isOpened: false,
     pages: [],
   };
-  const swrResponse = useStaticSWR<PrivateLegacyPagesMigrationModalStatus, Error>('privateLegacyPagesMigrationModal', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<PrivateLegacyPagesMigrationModalStatus, Error>('privateLegacyPagesMigrationModal', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -362,7 +360,7 @@ export const useDescendantsPageListModal = (
 ): SWRResponse<DescendantsPageListModalStatus, Error> & DescendantsPageListUtils => {
 
   const initialData: DescendantsPageListModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<DescendantsPageListModalStatus, Error>('descendantsPageListModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<DescendantsPageListModalStatus, Error>('descendantsPageListModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -445,7 +443,7 @@ type UpdateUserGroupConfirmModalUtils = {
 export const useUpdateUserGroupConfirmModal = (): SWRResponse<UpdateUserGroupConfirmModalStatus, Error> & UpdateUserGroupConfirmModalUtils => {
 
   const initialStatus: UpdateUserGroupConfirmModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<UpdateUserGroupConfirmModalStatus, Error>('updateParentConfirmModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<UpdateUserGroupConfirmModalStatus, Error>('updateParentConfirmModal', undefined, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
@@ -475,7 +473,7 @@ type ShortcutsModalUtils = {
 export const useShortcutsModal = (): SWRResponse<ShortcutsModalStatus, Error> & ShortcutsModalUtils => {
 
   const initialStatus: ShortcutsModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<ShortcutsModalStatus, Error>('shortcutsModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<ShortcutsModalStatus, Error>('shortcutsModal', undefined, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
@@ -514,7 +512,7 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
     isOpened: false,
     drawioMxFile: '',
   };
-  const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
 
   const { mutate } = swrResponse;
 
@@ -575,7 +573,7 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
     autoFormatMarkdownTable: false,
   };
 
-  const swrResponse = useStaticSWR<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
 
   const { mutate } = swrResponse;
 
@@ -616,16 +614,22 @@ type ConflictDiffModalUtils = {
 export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Error> & ConflictDiffModalUtils => {
 
   const initialStatus: ConflictDiffModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<ConflictDiffModalStatus, Error>('conflictDiffModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<ConflictDiffModalStatus, Error>('conflictDiffModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
 
-  return Object.assign(swrResponse, {
-    open: (requestRevisionBody: string, onResolve: ResolveConflictHandler) => {
-      swrResponse.mutate({ isOpened: true, requestRevisionBody, onResolve });
-    },
-    close: () => {
-      swrResponse.mutate({ isOpened: false });
-    },
-  });
+  const open = useCallback((requestRevisionBody: string, onResolve: ResolveConflictHandler) => {
+    mutate({ isOpened: true, requestRevisionBody, onResolve });
+  }, [mutate]);
+
+  const close = useCallback(() => {
+    mutate({ isOpened: false });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
 };
 
 /*
@@ -654,7 +658,7 @@ export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalS
   const initialData: DeleteBookmarkFolderModalStatus = {
     isOpened: false,
   };
-  const swrResponse = useStaticSWR<DeleteBookmarkFolderModalStatus, Error>('deleteBookmarkFolderModalStatus', status, { fallbackData: initialData });
+  const swrResponse = useSWRStatic<DeleteBookmarkFolderModalStatus, Error>('deleteBookmarkFolderModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -696,7 +700,7 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
     attachment: undefined,
     remove: undefined,
   };
-  const swrResponse = useStaticSWR<DeleteAttachmentModalStatus, Error>('deleteAttachmentModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<DeleteAttachmentModalStatus, Error>('deleteAttachmentModal', undefined, { fallbackData: initialStatus });
   const { mutate } = swrResponse;
 
   const open = useCallback((attachment: IAttachmentHasId, remove: Remove) => {
@@ -734,7 +738,7 @@ export const usePageSelectModal = (
     status?: PageSelectModalStatus,
 ): SWRResponse<PageSelectModalStatus, Error> & PageSelectModalStatusUtils => {
   const initialStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<PageSelectModalStatus, Error>('PageSelectModal', status, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<PageSelectModalStatus, Error>('PageSelectModal', status, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
@@ -772,7 +776,7 @@ export const useTagEditModal = (): SWRResponse<TagEditModalStatus, Error> & TagE
     };
   }, []);
 
-  const swrResponse = useStaticSWR<TagEditModalStatus, Error>('TagEditModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<TagEditModalStatus, Error>('TagEditModal', undefined, { fallbackData: initialStatus });
   const { mutate } = swrResponse;
 
   const open = useCallback(async(tags: string[], pageId: string, revisionId: string) => {

+ 19 - 0
apps/app/src/styles/_marker.scss

@@ -0,0 +1,19 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+// Light mode color
+@include bs.color-mode(light) {
+  --grw-marker-bg-yellow: #FFFA90;
+  --grw-marker-bg-red: #FFAADD;
+  --grw-marker-bg-blue: #9AE0FF;
+  --grw-marker-bg-cyan: #88FFF0;
+  --grw-marker-bg-green: #B8FF9A;
+}
+
+// dark mode color
+@include bs.color-mode(dark) {
+  --grw-marker-bg-yellow: #888000;
+  --grw-marker-bg-red: #900066;
+  --grw-marker-bg-blue: #0A6A9A;
+  --grw-marker-bg-cyan: #008888;
+  --grw-marker-bg-green: #007000;
+}

+ 0 - 7
apps/app/src/styles/_variables.scss

@@ -1,10 +1,3 @@
-// == Marker Color
-$grw-marker-yellow: #ff6;
-$grw-marker-red: #f6c;
-$grw-marker-blue: #6cf;
-$grw-marker-cyan: #6ff;
-$grw-marker-green: #6f6;
-
 // == Layout
 $grw-sidebar-nav-width: 48px;
 $grw-navbar-bottom-height: 62px;

+ 1 - 1
apps/app/src/styles/style-app.scss

@@ -25,7 +25,7 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'share-link';
-
+@import 'marker';
 
 /*
  * for Guest User Mode

+ 0 - 95
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts

@@ -1,95 +0,0 @@
-const openPageAccessoriesModal = () => {
-  cy.visit('/');
-  cy.collapseSidebar(true);
-  cy.waitUntilSkeletonDisappear();
-
-  // open PageAccessoriesModal
-  cy.getByTestid('pageListButton').should('be.visible').click();
-  cy.getByTestid('descendants-page-list-modal').then($elem => $elem.is(':visible'));
-
-  // cy.waitUntil(() => {
-  //   // do
-  //   cy.getByTestid('pageListButton').click();
-  //   // wait until
-  //   return cy.getByTestid('descendants-page-list-modal').then($elem => $elem.is(':visible'));
-  // });
-
-  cy.waitUntilSpinnerDisappear();
-}
-
-context('Access to pagelist', () => {
-  const ssPrefix = 'access-to-pagelist-';
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    openPageAccessoriesModal();
-  });
-
-  it('Page list modal is successfully opened ', () => {
-    // Wait until the string "You cannot see this page" is no longer displayed
-    cy.getByTestid('page-list-item-L').eq(0).within(() => {
-      cy.get('.material-symbols-outlined').contains('error').should('not.exist');
-    });
-
-    cy.waitUntilSpinnerDisappear();
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}1-open-pagelist-modal`);
-  });
-
-  it('Successfully open PageItemControl', () => {
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('descendants-page-list-modal').within(() => {
-        cy.getByTestid('page-list-item-L').first().within(() => {
-          cy.getByTestid('open-page-item-control-btn').click();
-        });
-      });
-      // wait until
-      return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('.dropdown-menu.show').within(() => {
-      cy.getByTestid('open-page-duplicate-modal-btn').should('be.visible')
-    });
-
-    cy.waitUntilSkeletonDisappear();
-    cy.waitUntilSpinnerDisappear();
-    cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
-  });
-
-  it('Successfully expand and close modal', () => {
-    cy.get('.btn-close').eq(0).click();
-
-    cy.waitUntilSkeletonDisappear();
-    cy.waitUntilSpinnerDisappear();
-    cy.screenshot(`${ssPrefix}7-page-list-modal-size-fullscreen`);
-
-    // Check that the modal has been closed
-    cy.getByTestid('descendants-page-list-modal').should('not.be.visible')
-    cy.screenshot(`${ssPrefix}8-close-page-list-modal`);
-  });
-});
-
-context('Access to timeline', () => {
-  const ssPrefix = 'access-to-timeline-';
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    openPageAccessoriesModal();
-  });
-
-  it('Timeline list successfully openend', () => {
-    cy.getByTestid('descendants-page-list-modal').parent().should('have.class','show').within(() => {
-      cy.get('.nav-title > li').eq(1).find('a').click();
-    });
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(500); // wait for loading wiki
-    cy.screenshot(`${ssPrefix}1-timeline-list`);
-  });
-});

+ 0 - 176
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts

@@ -1,176 +0,0 @@
-context('Click page icons button', () => {
-  const ssPrefix = 'click-page-icon-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Successfully subscribe/unsubscribe a page', () => {
-    cy.visit('/Sandbox');
-    cy.collapseSidebar(true, true);
-
-    // Subscribe
-    cy.get('#subscribe-button').click({force: true});
-    cy.get('#subscribe-button').should('have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="subscribe-button-tooltip"]').length > 0) {
-        cy.getByTestid('subscribe-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('subscribe-button-tooltip').should('not.exist');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}1-subscribe-page`) })
-
-    // Unsubscribe
-    cy.get('#subscribe-button').click({force: true});
-    cy.get('#subscribe-button').should('not.have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="subscribe-button-tooltip"]').length > 0) {
-        cy.getByTestid('subscribe-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('subscribe-button-tooltip').should('not.exist');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}2-unsubscribe-page`) })
-  });
-
-  it('Successfully Like / Dislike a page', () => {
-    cy.visit('/Sandbox');
-    cy.collapseSidebar(true);
-
-    // like
-    cy.get('#like-button').click({force: true});
-    cy.get('#like-button').should('have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="like-button-tooltip"]').length > 0) {
-        cy.getByTestid('like-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('like-button-tooltip').should('not.exist');
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}3-like-page`) });
-
-    // total liker (user-list-popover is commented out because it is sometimes displayed and sometimes not.)
-    // cy.get('#po-total-likes').click({force: true});
-    // cy.get('.user-list-popover').should('be.visible')
-    // cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}4-likes-counter`) });
-
-    // unlike
-    cy.get('#like-button').click({force: true});
-    cy.get('#like-button').should('not.have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="like-button-tooltip"]').length > 0) {
-        cy.getByTestid('like-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('like-button-tooltip').should('not.exist');
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}5-dislike-page`) });
-
-    // total liker (user-list-popover is commented out because it is sometimes displayed and sometimes not.)
-    // cy.get('#po-total-likes').click({force: true});
-    // cy.get('.user-list-popover').should('be.visible');
-    // cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}6-likes-counter`) });
-  });
-
-  it('Successfully Bookmark / Unbookmark a page', () => {
-    cy.visit('/Sandbox');
-    cy.collapseSidebar(true);
-
-    // bookmark
-    cy.get('#bookmark-dropdown-btn').click({force: true});
-    cy.get('#bookmark-dropdown-btn').should('have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="bookmark-button-tooltip"]').length > 0) {
-        cy.getByTestid('bookmark-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('bookmark-button-tooltip').should('not.exist');
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}7-bookmark-page`) });
-
-    // total bookmarker
-    cy.waitUntil(() => {
-      // do
-      cy.get('#po-total-bookmarks').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('.user-list-popover').is(':visible');
-      });
-    });
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}8-bookmarks-counter`) });
-
-    // unbookmark
-    cy.get('#bookmark-dropdown-btn').click({force: true});
-    cy.get('.grw-bookmark-folder-menu').should('be.visible');
-    cy.get('.grw-bookmark-folder-menu-item').first().click({force: true});
-    cy.get('#bookmark-dropdown-btn').should('not.have.class', 'active');
-
-    // position of the element is not fixed to be displayed, so the element is removed
-    cy.get('body').then($body => {
-      if ($body.find('[data-testid="bookmark-button-tooltip"]').length > 0) {
-        cy.getByTestid('bookmark-button-tooltip').invoke('remove');
-      }
-    })
-    cy.getByTestid('bookmark-button-tooltip').should('not.exist');
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}9-unbookmark-page`) });
-
-    // total bookmarker
-    cy.waitUntil(() => {
-      // do
-      cy.get('#po-total-bookmarks').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('.user-list-popover').is(':visible');
-      });
-    });
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('grw-contextual-sub-nav').within(() => { cy.screenshot(`${ssPrefix}10-bookmarks-counter`) });
-  });
-
-  // user-list-popover is commented out because it is sometimes displayed and sometimes not
-  // it('Successfully display list of "seen by user"', () => {
-  //   cy.visit('/Sandbox');
-  //   cy.waitUntilSkeletonDisappear();
-
-  //   cy.getByTestid('grw-contextual-sub-nav').within(() => {
-  //     cy.get('div.grw-seen-user-info').find('button#btn-seen-user').click({force: true});
-  //   });
-
-  //   // position of the element is not fixed to be displayed, so the element is removed
-  //   cy.get('body').then($body => {
-  //     if ($body.find('[data-testid="seen-user-info-tooltip"]').length > 0) {
-  //       cy.getByTestid('seen-user-info-tooltip').invoke('remove');
-  //     }
-  //   })
-  //   cy.getByTestid('seen-user-info-tooltip').should('not.exist');
-
-  //   cy.get('.user-list-popover').should('be.visible')
-
-  //   cy.getByTestid('grw-contextual-sub-nav').within(() => {
-  //     cy.screenshot(`${ssPrefix}11-seen-user-list`);
-  //   });
-  // });
-
-});

+ 0 - 140
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts

@@ -1,140 +0,0 @@
-context('Comment', () => {
-  const ssPrefix = 'comments-';
-  let commentCount = 0;
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    // visit page
-    cy.visit('/comment');
-    cy.collapseSidebar(true, true);
-  })
-
-  it('Create comment page', () => {
-    // save page
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    });
-    cy.get('.cm-content').should('be.visible');
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
-  })
-
-  it('Successfully add comments', () => {
-    const commetText = 'add comment';
-
-    cy.getByTestid('page-comment-button').click();
-
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('.cm-content').type(commetText);
-    cy.getByTestid("comment-submit-button").eq(0).click();
-
-    // Check update comment count
-    commentCount += 1
-    cy.getByTestid('page-comment-button').contains(commentCount);
-    cy.screenshot(`${ssPrefix}1-add-comments`);
-  });
-
-  it('Successfully reply comments', () => {
-    const commetText = 'reply comment';
-
-    cy.getByTestid('page-comment-button').click();
-
-    // Open reply comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('comment-reply-button').eq(0).click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('.cm-content').type(commetText);
-    cy.getByTestid("comment-submit-button").eq(0).click();
-
-    // TODO : https://redmine.weseek.co.jp/issues/139431
-    // Check update comment count
-    // commentCount += 1
-    // cy.getByTestid('page-comment-button').contains(commentCount);
-    // cy.screenshot(`${ssPrefix}2-reply-comments`);
-  });
-
-  // TODO:https://redmine.weseek.co.jp/issues/139467
-  // it('Successfully delete comments', () => {
-
-  //   cy.getByTestid('page-comment-button').click();
-
-  //   cy.get('.page-comments').should('be.visible');
-  //   cy.getByTestid('comment-delete-button').eq(0).click({force: true});
-  //   cy.get('.modal-content').then($elem => $elem.is(':visible'));
-  //   cy.get('.modal-footer > button:nth-child(3)').click();
-
-  //   // Check update comment count
-  //   commentCount -= 2
-  //   cy.getByTestid('page-comment-button').contains(commentCount);
-  //   cy.screenshot(`${ssPrefix}3-delete-comments`);
-  // });
-
-
-  // TODO: https://redmine.weseek.co.jp/issues/139520
-  // // Mention username in comment
-  // it('Successfully mention username in comment', () => {
-  //   const username = '@adm';
-
-  //   cy.getByTestid('page-comment-button').click();
-
-  //   // Open comment editor
-  //   cy.waitUntil(() => {
-  //     // do
-  //     cy.getByTestid('open-comment-editor-button').click();
-  //     // wait until
-  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-  //   });
-
-  //   cy.appendTextToEditorUntilContains(username);
-
-  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
-  //   // Click on mentioned username
-  //   cy.get('.CodeMirror-hints > li').first().click();
-  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
-  // });
-
-  // TODO: https://redmine.weseek.co.jp/issues/139520
-  // it('Username not found when mention username in comment', () => {
-  //   const username = '@user';
-
-  //   cy.getByTestid('page-comment-button').click();
-
-  //   // Open comment editor
-  //   cy.waitUntil(() => {
-  //     // do
-  //     cy.getByTestid('open-comment-editor-button').click();
-  //     // wait until
-  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-  //   });
-
-  //   cy.appendTextToEditorUntilContains(username);
-
-  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
-  //   // Click on username not found hint
-  //   cy.get('.CodeMirror-hints > li').first().click();
-  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
-  // });
-
-})

+ 0 - 93
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts

@@ -1,93 +0,0 @@
-context('Access to any page', () => {
-  const ssPrefix = 'subnav-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    cy.visit('/');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true, true);
-  });
-
-  it('Subnavigation displays changes on scroll down and up', () => {
-    cy.waitUntil(() => {
-      // do
-      // Scroll the window 250px down is enough to trigger sticky effect
-       cy.scrollTo(0, 250);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
-    });
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}visible-on-scroll-down`);
-
-    cy.waitUntil(() => {
-      // do
-      // Scroll the window back to top
-      cy.scrollTo(0, 0);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
-    });
-
-    cy.screenshot(`${ssPrefix}invisible-on-scroll-top`);
-  });
-
-  it('Subnavigation is not displayed when move to other pages', () => {
-    cy.waitUntil(() => {
-      // do
-      // Scroll the window 250px down is enough to trigger sticky effect
-      cy.scrollTo(0, 250);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
-    });
-
-    // Move to /Sandbox page
-    cy.visit('/Sandbox');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
-
-    return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
-    cy.screenshot(`${ssPrefix}not-visible-on-move-to-other-pages`);
-  });
-
-  it('Able to click buttons on subnavigation switcher when sticky', () => {
-    cy.waitUntil(() => {
-      // do
-      // Scroll the window 250px down is enough to trigger sticky effect
-      cy.scrollTo(0, 250);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
-    });
-    cy.waitUntil(() => {
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('editor-button').as('editorButton').should('be.visible');
-        cy.get('@editorButton').click();
-      });
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    });
-    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
-    // cy.get('.CodeMirror').should('be.visible');
-    cy.screenshot(`${ssPrefix}open-editor-when-sticky`);
-  });
-
-  it('Subnavigation is sticky when on small window', () => {
-    cy.waitUntil(() => {
-      // do
-      // Scroll the window 500px down
-      cy.scrollTo(0, 500);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
-    });
-    cy.waitUntilSkeletonDisappear();
-    cy.viewport(600, 1024);
-    cy.getByTestid('grw-contextual-sub-nav').within(() => {
-      cy.get('#grw-page-editor-mode-manager').should('be.visible');
-    })
-    cy.screenshot(`${ssPrefix}sticky-on-small-window`);
-  });
-});

+ 0 - 73
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts

@@ -1,78 +1,5 @@
-context('Access to page by guest', () => {
-  const ssPrefix = 'access-to-page-by-guest-';
-
-  it('/Sandbox is successfully loaded', () => {
-    cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
-
-    cy.collapseSidebar(true, true);
-    cy.screenshot(`${ssPrefix}-sandbox`);
-  });
-
-  // TODO: https://redmine.weseek.co.jp/issues/109939
-  it('/Sandbox with anchor hash is successfully loaded', () => {
-    cy.visit('/Sandbox#headers');
-    cy.collapseSidebar(true);
-
-    // assert the element is in viewport
-    cy.get('#headers').should('be.inViewport');
-
-    // remove animation for screenshot
-    // remove 'blink' class because ::after element cannot be operated
-    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
-    cy.get('#headers').invoke('removeClass', 'blink');
-
-    // cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox-headers`);
-  });
-
-  it('/Sandbox/Math is successfully loaded', () => {
-    cy.visit('/Sandbox/Math');
-    cy.collapseSidebar(true);
-
-    // for check download toc data
-    // https://redmine.weseek.co.jp/issues/111384
-    // cy.get('.toc-link').should('be.visible');
-
-    cy.get('.math').should('be.visible');
-
-    // cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox-math`);
-  });
-
-  it('/Sandbox with edit is successfully loaded', () => {
-    cy.visit('/Sandbox#edit');
-    cy.collapseSidebar(true);
-
-    // cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox-with-edit-hash`);
-  })
-
-});
-
-
-context('Access to /me page', () => {
-  const ssPrefix = 'access-to-me-page-by-guest-';
-
-  it('/me should be redirected to /login', () => {
-    cy.visit('/me');
-    cy.getByTestid('login-form').should('be.visible');
-    cy.screenshot(`${ssPrefix}-me`);
-  });
-
-});
-
-
 context('Access to special pages by guest', () => {
   const ssPrefix = 'access-to-special-pages-by-guest-';
-
-  it('/trash is successfully loaded', () => {
-    cy.visit('/trash', {  });
-    cy.getByTestid('trash-page-list').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-trash`);
-  });
-
   it('/tags is successfully loaded', () => {
     cy.visit('/tags');
 

+ 0 - 30
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts

@@ -1,30 +0,0 @@
-context('Access sticky sub navigation switcher for guest', () => {
-  const ssPrefix = 'access-sticky-by-guest-';
-
-  it('Sub navigation sticky changes when scrolling down and up', () => {
-    cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true, true);
-
-    // Sticky
-    cy.waitUntil(() => {
-      // do
-      // Scroll page down 250px
-      cy.scrollTo(0, 250);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
-    });
-    cy.screenshot(`${ssPrefix}subnav-switcher-is-sticky-on-scroll-down`);
-
-    // Not sticky
-    cy.waitUntil(() => {
-      // do
-      // Scroll page to top
-      cy.scrollTo(0, 0);
-      // wait until
-      return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
-    });
-    cy.screenshot(`${ssPrefix}subnav-switcher-is-not-sticky-on-scroll-top`);
-  });
-
-});

+ 0 - 71
apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts

@@ -1,71 +0,0 @@
-context('Access to sharelink by guest', () => {
-  const ssPrefix = 'access-to-sharelink-by-guest-';
-
-  let createdSharelinkId: string;
-
-  it('Prepare sharelink', () => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    cy.visit('/Sandbox/Bootstrap5');
-
-    // open dropdown
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn', { timeout: 14000 }).find('button').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-
-    // open modal
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').click({force: true});
-    });
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('page-accessories-modal').should('be.visible');
-    cy.getByTestid('share-link-management').should('be.visible');
-
-    // create share link
-    cy.getByTestid('share-link-management').within(() => {
-      // open form
-      cy.waitUntil(() => {
-        // do
-        cy.getByTestid('btn-sharelink-toggleform').click();
-        // wait until
-        return cy.getByTestid('btn-sharelink-issue').then($elem => $elem.is(':visible'))
-      });
-
-      cy.getByTestid('btn-sharelink-issue').should('be.visible').click();
-
-      cy.get('tbody')
-        .find('tr').first() // the first row
-        .find('td').first() // the first column
-        .find('span').first().then((elem) => {
-
-        // store id
-        createdSharelinkId = elem.text();
-        // overwrite the label
-        elem.html('63d100000000000000000000');
-      });
-    });
-
-    cy.getByTestid('page-accessories-modal').within(() => { cy.screenshot(`${ssPrefix}-sharelink-created`) });
-  });
-
-  it('The sharelink page is successfully loaded', () => {
-    cy.visit(`/share/${createdSharelinkId}`);
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
-    cy.get('.wiki').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-access-to-sharelink`);
-  });
-
-});
-
-

+ 1 - 0
apps/slackbot-proxy/tsconfig.json

@@ -3,6 +3,7 @@
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
     "module": "CommonJS",
+    "moduleResolution": "Node",
 
     "baseUrl": ".",
     "paths": {

+ 34 - 1
packages/editor/src/client/components/CodeMirrorEditorReadOnly.tsx

@@ -1,6 +1,7 @@
 import { useEffect } from 'react';
 
-import { type Extension, EditorState } from '@codemirror/state';
+import { type Extension, EditorState, Prec } from '@codemirror/state';
+import { EditorView, keymap } from '@codemirror/view';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { setDataLine } from '../services-internal';
@@ -29,6 +30,38 @@ export const CodeMirrorEditorReadOnly = ({ markdown, onScroll }: Props): JSX.Ele
     return codeMirrorEditor?.appendExtensions?.(additionalExtensions);
   }, [codeMirrorEditor]);
 
+
+  // prevent Ctrl+V and paste
+  useEffect(() => {
+    const extension = keymap.of([
+      {
+        key: 'Mod-v',
+        preventDefault: true,
+        run: () => {
+          return true;
+        },
+      },
+    ]);
+    const cleanupFunction = codeMirrorEditor?.appendExtensions?.(extension);
+
+    return cleanupFunction;
+  }, [codeMirrorEditor]);
+
+  useEffect(() => {
+    const handlePaste = (event: ClipboardEvent) => {
+      event.preventDefault();
+      return;
+    };
+    const extension = EditorView.domEventHandlers({
+      paste: handlePaste,
+    });
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions?.(Prec.high(extension));
+
+    return cleanupFunction;
+  }, [codeMirrorEditor]);
+
+
   return (
     <CodeMirrorEditor
       hideToolbar

+ 6 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @growi/pluginkit
 
+## 1.0.1
+
+### Patch Changes
+
+- [#8898](https://github.com/weseek/growi/pull/8898) [`7a50227`](https://github.com/weseek/growi/commit/7a502271b35bae4b419e54a08b2b00c7b140db46) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Update tsconfig.json module setting
+
 ## 1.0.0
 
 ### Major Changes

+ 1 - 1
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pluginkit",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "main": "dist/index.cjs",

+ 3 - 0
packages/preset-themes/src/styles/antarctic.scss

@@ -29,6 +29,9 @@
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-600-rgb);
   --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-cyan);
+
   &, body {
     background-image: url('../images/antarctic/antarctic.svg');
     background-attachment: fixed;

+ 3 - 0
packages/preset-themes/src/styles/blackboard.scss

@@ -28,6 +28,9 @@
   --grw-wiki-link-color-rgb: var(--grw-primary-400-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-300-rgb);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-red);
+
   &, body {
     background-image: url('../images/blackboard/blackboard.png');
     background-attachment: fixed;

+ 3 - 0
packages/preset-themes/src/styles/classic.scss

@@ -54,4 +54,7 @@
   @import '@growi/core-styles/scss/bootstrap/theming/apply-dark';
 
   --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
+
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-red);
 }

+ 1 - 0
packages/preset-themes/src/styles/default.scss

@@ -58,4 +58,5 @@
   --grw-wiki-link-color-rgb: var(--grw-highlight-600-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-highlight-400-rgb);
   --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
+
 }

+ 6 - 0
packages/preset-themes/src/styles/fire-red.scss

@@ -27,6 +27,9 @@
 
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-600-rgb);
+
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-cyan);
 }
 
 :root[data-bs-theme='dark'] {
@@ -56,4 +59,7 @@
 
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-400-rgb);
+
+    // change marker color
+    --grw-marker-bg: var(--grw-marker-bg-cyan);
 }

+ 3 - 0
packages/preset-themes/src/styles/future.scss

@@ -27,4 +27,7 @@
 
   --grw-wiki-link-color-rgb: var(--grw-primary-400-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-300-rgb);
+
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-cyan);
 }

+ 2 - 0
packages/preset-themes/src/styles/halloween.scss

@@ -39,4 +39,6 @@
   --grw-wiki-link-color-rgb: var(--grw-primary-400-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-300-rgb);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-red);
 }

+ 6 - 0
packages/preset-themes/src/styles/hufflepuff.scss

@@ -28,6 +28,9 @@
   --grw-wiki-link-color-rgb: var(--grw-primary-600-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-800-rgb);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-cyan);
+
   &, body {
     background-image: url('../images/hufflepuff/hufflepuff-light-bg.svg');
     background-attachment: fixed;
@@ -65,6 +68,9 @@
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-400-rgb);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-cyan);
+
   &, body {
     background-image: url('../images/hufflepuff/hufflepuff-dark-bg.svg');
     background-attachment: fixed;

+ 6 - 0
packages/preset-themes/src/styles/jade-green.scss

@@ -29,6 +29,9 @@ $min-contrast-ratio: 2;
 
   --grw-wiki-link-color-rgb: var(--grw-primary-700-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-800-rgb);
+
+    // change marker color
+    --grw-marker-bg: var(--grw-marker-bg-red);
 }
 
 :root[data-bs-theme='dark'] {
@@ -58,4 +61,7 @@ $min-contrast-ratio: 2;
 
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-400-rgb);
+
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-red);
 }

+ 3 - 0
packages/preset-themes/src/styles/nature.scss

@@ -27,4 +27,7 @@
 
   --grw-wiki-link-color-rgb: var(--grw-primary-600-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-700-rgb);
+
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-blue);
 }

+ 3 - 0
packages/preset-themes/src/styles/wood.scss

@@ -28,6 +28,9 @@
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-600-rgb);
 
+  // change marker color
+  --grw-marker-bg: var(--grw-marker-bg-green);
+
   &, body {
     background-image: url('../images/wood/wood.svg');
   }

+ 35 - 5
yarn.lock

@@ -2136,7 +2136,7 @@
     react-dom "^18.2.0"
 
 "@growi/pluginkit@link:packages/pluginkit":
-  version "1.0.0"
+  version "1.0.1"
   dependencies:
     "@growi/core" "^1.0.0"
     extensible-custom-error "^0.0.7"
@@ -17258,7 +17258,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
-"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17276,6 +17276,15 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17359,7 +17368,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17373,6 +17382,13 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19204,7 +19220,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19222,6 +19238,15 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
@@ -19267,7 +19292,12 @@ ws@^7.3.1:
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
   integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
 
-ws@^8.3.0, ws@~8.11.0:
+ws@^8.17.1:
+  version "8.17.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
+  integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
+
+ws@~8.11.0:
   version "8.11.0"
   resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
   integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==