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

Merge pull request #9988 from weseek/master

Release v7.2.6
mergify[bot] 10 месяцев назад
Родитель
Сommit
de70174f1a
100 измененных файлов с 1684 добавлено и 923 удалено
  1. 0 1
      .eslintignore
  2. 7 1
      .eslintrc.js
  3. 0 6
      apps/app/.eslintignore
  4. 14 2
      apps/app/.eslintrc.js
  5. 9 0
      apps/app/bin/openapi/definition-apiv1.js
  6. 9 0
      apps/app/bin/openapi/definition-apiv3.js
  7. 96 0
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  8. 29 0
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  9. 219 0
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  10. 62 0
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts
  11. 15 0
      apps/app/bin/openapi/generate-spec-apiv1.sh
  12. 9 4
      apps/app/bin/openapi/generate-spec-apiv3.sh
  13. 0 15
      apps/app/bin/swagger-jsdoc/generate-spec-apiv1.sh
  14. 12 8
      apps/app/package.json
  15. 2 2
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  16. 0 1
      apps/app/public/static/locales/en_US/admin.json
  17. 1 0
      apps/app/public/static/locales/en_US/translation.json
  18. 0 1
      apps/app/public/static/locales/fr_FR/admin.json
  19. 1 0
      apps/app/public/static/locales/fr_FR/translation.json
  20. 20 21
      apps/app/public/static/locales/ja_JP/admin.json
  21. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  22. 0 1
      apps/app/public/static/locales/zh_CN/admin.json
  23. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  24. 94 52
      apps/app/src/client/components/Admin/Security/SecuritySetting.jsx
  25. 26 3
      apps/app/src/client/components/PageControls/PageControls.tsx
  26. 5 3
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx
  27. 23 19
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  28. 0 15
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  29. 129 116
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  30. 0 126
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx
  31. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss
  32. 95 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx
  33. 25 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx
  34. 13 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx
  35. 84 56
      apps/app/src/features/openai/client/services/editor-assistant.tsx
  36. 2 18
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  37. 1 1
      apps/app/src/features/openai/server/routes/delete-thread.ts
  38. 57 23
      apps/app/src/features/openai/server/routes/edit/index.ts
  39. 1 1
      apps/app/src/features/openai/server/routes/get-threads.ts
  40. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  41. 6 2
      apps/app/src/pages/[[...path]].page.tsx
  42. 10 0
      apps/app/src/server/models/openapi/object-id.ts
  43. 11 9
      apps/app/src/server/models/openapi/page.ts
  44. 12 8
      apps/app/src/server/models/openapi/paginate-result.ts
  45. 12 10
      apps/app/src/server/models/openapi/revision.ts
  46. 32 0
      apps/app/src/server/models/openapi/tag.ts
  47. 4 3
      apps/app/src/server/models/openapi/v1-response.js
  48. 0 1
      apps/app/src/server/routes/apiv3/admin-home.ts
  49. 79 41
      apps/app/src/server/routes/apiv3/app-settings.js
  50. 0 2
      apps/app/src/server/routes/apiv3/attachment.js
  51. 0 6
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  52. 1 4
      apps/app/src/server/routes/apiv3/bookmarks.js
  53. 1 17
      apps/app/src/server/routes/apiv3/customize-setting.js
  54. 0 3
      apps/app/src/server/routes/apiv3/export.js
  55. 2 0
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  56. 0 1
      apps/app/src/server/routes/apiv3/healthcheck.ts
  57. 1 5
      apps/app/src/server/routes/apiv3/import.js
  58. 0 4
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  59. 0 1
      apps/app/src/server/routes/apiv3/installer.ts
  60. 0 1
      apps/app/src/server/routes/apiv3/invited.ts
  61. 0 4
      apps/app/src/server/routes/apiv3/markdown-setting.js
  62. 0 1
      apps/app/src/server/routes/apiv3/mongo.js
  63. 12 23
      apps/app/src/server/routes/apiv3/page/index.ts
  64. 5 47
      apps/app/src/server/routes/apiv3/pages/index.js
  65. 0 14
      apps/app/src/server/routes/apiv3/personal-setting.js
  66. 0 13
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  67. 0 1
      apps/app/src/server/routes/apiv3/statistics.js
  68. 0 1
      apps/app/src/server/routes/apiv3/user-activation.ts
  69. 0 1
      apps/app/src/server/routes/apiv3/user-group-relation.js
  70. 0 15
      apps/app/src/server/routes/apiv3/user-group.js
  71. 0 18
      apps/app/src/server/routes/apiv3/users.js
  72. 5 5
      apps/app/src/server/routes/attachment/api.js
  73. 29 27
      apps/app/src/server/routes/comment.js
  74. 4 4
      apps/app/src/server/routes/page.js
  75. 22 20
      apps/app/src/server/routes/search.ts
  76. 7 36
      apps/app/src/server/routes/tag.js
  77. 18 14
      apps/app/src/server/service/config-manager/config-definition.ts
  78. 87 0
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  79. 104 4
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  80. 24 12
      apps/app/src/server/service/config-manager/config-manager.ts
  81. 9 5
      apps/app/src/server/service/file-uploader/aws/index.ts
  82. 4 3
      apps/app/src/server/service/file-uploader/azure.ts
  83. 3 2
      apps/app/src/server/service/file-uploader/gcs/index.ts
  84. 1 11
      apps/app/src/server/service/page/index.ts
  85. 6 0
      apps/app/src/stores-universal/context.tsx
  86. 1 2
      apps/app/src/stores/page-listing.tsx
  87. 1 1
      apps/app/vitest.workspace.mts
  88. 0 1
      apps/pdf-converter/.eslintignore
  89. 6 0
      apps/pdf-converter/.eslintrc.cjs
  90. 11 7
      apps/pdf-converter/package.json
  91. 55 0
      apps/pdf-converter/src/controllers/pdf.spec.ts
  92. 8 3
      apps/pdf-converter/src/controllers/pdf.ts
  93. 5 6
      apps/pdf-converter/src/service/pdf-convert.ts
  94. 2 1
      apps/pdf-converter/tsconfig.json
  95. 12 0
      apps/pdf-converter/vitest.config.ts
  96. 1 1
      apps/slackbot-proxy/package.json
  97. 2 3
      biome.json
  98. 1 1
      package.json
  99. 4 1
      packages/core/src/interfaces/config-manager.ts
  100. 1 0
      packages/core/src/interfaces/index.ts

+ 0 - 1
.eslintignore

@@ -1 +0,0 @@
-node_modules/**

+ 7 - 1
.eslintrc.js

@@ -1,3 +1,6 @@
+/**
+ * @type {import('eslint').Linter.Config}
+ */
 module.exports = {
   root: true, // https://eslint.org/docs/user-guide/configuring/configuration-files#cascading-and-hierarchy
   extends: [
@@ -7,6 +10,9 @@ module.exports = {
   plugins: [
     'regex',
   ],
+  ignorePatterns: [
+    'node_modules/**',
+  ],
   rules: {
     'import/prefer-default-export': 'off',
     'import/order': [
@@ -73,7 +79,7 @@ module.exports = {
   overrides: [
     {
       // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.tsx'],
+      files: ['*.ts', '*.mts', '*.tsx'],
       rules: {
         '@typescript-eslint/explicit-module-boundary-types': ['error'],
       },

+ 0 - 6
apps/app/.eslintignore

@@ -1,6 +0,0 @@
-/dist/**
-/transpiled/**
-/public/**
-/src/linter-checker/**
-/tmp/**
-/next-env.d.ts

+ 14 - 2
apps/app/.eslintrc.js

@@ -1,3 +1,6 @@
+/**
+ * @type {import('eslint').Linter.Config}
+ */
 module.exports = {
   extends: [
     'next/core-web-vitals',
@@ -5,6 +8,15 @@ module.exports = {
   ],
   plugins: [
   ],
+  ignorePatterns: [
+    'dist/**',
+    '**/dist/**',
+    'transpiled/**',
+    'public/**',
+    'src/linter-checker/**',
+    'tmp/**',
+    'next-env.d.ts',
+  ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     'import/resolver': {
@@ -25,7 +37,7 @@ module.exports = {
   overrides: [
     {
       // enable the rule specifically for JavaScript files
-      files: ['*.js', '*.jsx'],
+      files: ['*.js', '*.mjs', '*.jsx'],
       rules: {
         // set 'warn' temporarily -- 2023.08.14 Yuki Takei
         'react/prop-types': 'warn',
@@ -35,7 +47,7 @@ module.exports = {
     },
     {
       // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.tsx'],
+      files: ['*.ts', '*.mts', '*.tsx'],
       rules: {
         'no-unused-vars': 'off',
         // set 'warn' temporarily -- 2023.08.14 Yuki Takei

+ 9 - 0
apps/app/bin/swagger-jsdoc/definition-apiv1.js → apps/app/bin/openapi/definition-apiv1.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
   },
   servers: [
+    {
+      url: '{server}/_api',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
       url: 'https://demo.growi.org/_api',
     },

+ 9 - 0
apps/app/bin/swagger-jsdoc/definition-apiv3.js → apps/app/bin/openapi/definition-apiv3.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
   },
   servers: [
+    {
+      url: '{server}/_api/v3',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
       url: 'https://demo.growi.org/_api/v3',
     },

+ 96 - 0
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -0,0 +1,96 @@
+import { writeFileSync } from 'fs';
+
+import {
+  beforeEach, describe, expect, it, vi,
+} from 'vitest';
+
+import { generateOperationIds } from './generate-operation-ids';
+
+// Mock the modules
+vi.mock('fs');
+vi.mock('./generate-operation-ids');
+
+const originalArgv = process.argv;
+
+describe('cli', () => {
+  const mockJsonStrings = '{"test": "data"}';
+
+  beforeEach(() => {
+    vi.resetModules();
+    vi.resetAllMocks();
+    process.argv = [...originalArgv]; // Reset process.argv
+    // Mock console.error to avoid actual console output during tests
+    vi.spyOn(console, 'error').mockImplementation(() => {});
+  });
+
+  it('processes input file and writes output to specified file', async() => {
+    // Mock generateOperationIds to return success
+    vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
+
+    // Mock process.argv
+    process.argv = ['node', 'cli.js', 'input.json', '-o', 'output.json'];
+
+    // Import the module that contains the main function
+    const cliModule = await import('./cli');
+    await cliModule.main();
+
+    // Verify generateOperationIds was called with correct arguments
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+
+    // Verify writeFileSync was called with correct arguments
+    expect(writeFileSync).toHaveBeenCalledWith('output.json', mockJsonStrings);
+  });
+
+  it('uses input file as output when no output file is specified', async() => {
+    // Mock generateOperationIds to return success
+    vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
+
+    // Mock process.argv
+    process.argv = ['node', 'cli.js', 'input.json'];
+
+    // Import the module that contains the main function
+    const cliModule = await import('./cli');
+    await cliModule.main();
+
+    // Verify generateOperationIds was called with correct arguments
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+
+    // Verify writeFileSync was called with input file as output
+    expect(writeFileSync).toHaveBeenCalledWith('input.json', mockJsonStrings);
+  });
+
+  it('handles overwrite-existing option correctly', async() => {
+    // Mock generateOperationIds to return success
+    vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
+
+    // Mock process.argv
+    process.argv = ['node', 'cli.js', 'input.json', '--overwrite-existing'];
+
+    // Import the module that contains the main function
+    const cliModule = await import('./cli');
+    await cliModule.main();
+
+    // Verify generateOperationIds was called with overwriteExisting option
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: true });
+  });
+
+  it('handles generateOperationIds error correctly', async() => {
+    // Mock generateOperationIds to throw error
+    const error = new Error('Test error');
+    vi.mocked(generateOperationIds).mockRejectedValue(error);
+
+    // Mock process.argv
+    process.argv = ['node', 'cli.js', 'input.json'];
+
+    // Import the module that contains the main function
+    const cliModule = await import('./cli');
+    await cliModule.main();
+
+    // Verify error was logged
+    // eslint-disable-next-line no-console
+    expect(console.error).toHaveBeenCalledWith(error);
+
+    // Verify writeFileSync was not called
+    expect(writeFileSync).not.toHaveBeenCalled();
+  });
+});

+ 29 - 0
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -0,0 +1,29 @@
+import { writeFileSync } from 'fs';
+
+import { Command } from 'commander';
+
+import { generateOperationIds } from './generate-operation-ids';
+
+export const main = async(): Promise<void> => {
+  // parse command line arguments
+  const program = new Command();
+  program
+    .name('generate-operation-ids')
+    .description('Generate operationId for OpenAPI specification')
+    .argument('<input-file>', 'OpenAPI specification file')
+    .option('-o, --out <output-file>', 'Output file (defaults to input file)')
+    .option('--overwrite-existing', 'Overwrite existing operationId values')
+    .parse();
+  const { out: outputFile, overwriteExisting } = program.opts();
+  const [inputFile] = program.args;
+
+  // eslint-disable-next-line no-console
+  const jsonStrings = await generateOperationIds(inputFile, { overwriteExisting }).catch(console.error);
+  if (jsonStrings != null) {
+    writeFileSync(outputFile ?? inputFile, jsonStrings);
+  }
+};
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main();
+}

+ 219 - 0
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -0,0 +1,219 @@
+import fs from 'fs/promises';
+import { tmpdir } from 'os';
+import path from 'path';
+
+import type { OpenAPI3 } from 'openapi-typescript';
+import { describe, expect, it } from 'vitest';
+
+import { generateOperationIds } from './generate-operation-ids';
+
+
+async function createTempOpenAPIFile(spec: OpenAPI3): Promise<string> {
+  const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'openapi-test-'));
+  const filePath = path.join(tempDir, 'openapi.json');
+  await fs.writeFile(filePath, JSON.stringify(spec));
+  return filePath;
+}
+
+async function cleanup(filePath: string): Promise<void> {
+  try {
+    await fs.unlink(filePath);
+    await fs.rmdir(path.dirname(filePath));
+  }
+  catch (err) {
+    // eslint-disable-next-line no-console
+    console.error('Cleanup failed:', err);
+  }
+}
+
+describe('generateOperationIds', () => {
+  it('should generate correct operationId for simple paths', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo': {
+          get: {},
+          post: {},
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath);
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
+      expect(parsed.paths['/foo'].post.operationId).toBe('postFoo');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should generate correct operationId for paths with parameters', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo/{id}': {
+          get: {},
+        },
+        '/foo/{id}/bar/{page}': {
+          get: {},
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath);
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo/{id}'].get.operationId).toBe('getFooById');
+      expect(parsed.paths['/foo/{id}/bar/{page}'].get.operationId).toBe('getBarByPageByIdForFoo');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should generate correct operationId for nested resources', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo/bar': {
+          get: {},
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath);
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo/bar'].get.operationId).toBe('getBarForFoo');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should preserve existing operationId when overwriteExisting is false', async() => {
+    const existingOperationId = 'existingOperation';
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo': {
+          get: {
+            operationId: existingOperationId,
+          },
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath, { overwriteExisting: false });
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo'].get.operationId).toBe(existingOperationId);
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should overwrite existing operationId when overwriteExisting is true', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo': {
+          get: {
+            operationId: 'existingOperation',
+          },
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath, { overwriteExisting: true });
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should generate correct operationId for root path', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/': {
+          get: {},
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath);
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/'].get.operationId).toBe('getRoot');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should generate operationId for all HTTP methods', async() => {
+    const spec: OpenAPI3 = {
+      openapi: '3.0.0',
+      info: { title: 'Test API', version: '1.0.0' },
+      paths: {
+        '/foo': {
+          get: {},
+          post: {},
+          put: {},
+          delete: {},
+          patch: {},
+          options: {},
+          head: {},
+          trace: {},
+        },
+      },
+    };
+
+    const filePath = await createTempOpenAPIFile(spec);
+    try {
+      const result = await generateOperationIds(filePath);
+      const parsed = JSON.parse(result);
+
+      expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
+      expect(parsed.paths['/foo'].post.operationId).toBe('postFoo');
+      expect(parsed.paths['/foo'].put.operationId).toBe('putFoo');
+      expect(parsed.paths['/foo'].delete.operationId).toBe('deleteFoo');
+      expect(parsed.paths['/foo'].patch.operationId).toBe('patchFoo');
+      expect(parsed.paths['/foo'].options.operationId).toBe('optionsFoo');
+      expect(parsed.paths['/foo'].head.operationId).toBe('headFoo');
+      expect(parsed.paths['/foo'].trace.operationId).toBe('traceFoo');
+    }
+    finally {
+      await cleanup(filePath);
+    }
+  });
+
+  it('should throw error for non-existent file', async() => {
+    await expect(generateOperationIds('non-existent-file.json')).rejects.toThrow();
+  });
+});

+ 62 - 0
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts

@@ -0,0 +1,62 @@
+import SwaggerParser from '@apidevtools/swagger-parser';
+import type { OpenAPI3, OperationObject, PathItemObject } from 'openapi-typescript';
+
+const toPascal = (s: string): string => s.split('-').map(w => w[0]?.toUpperCase() + w.slice(1)).join('');
+
+const createParamSuffix = (params: string[]): string => {
+  return params.length > 0
+    ? params.reverse().map(param => `By${toPascal(param.slice(1, -1))}`).join('')
+    : '';
+};
+
+
+/**
+ * Generates a PascalCase operation name based on the HTTP method and path.
+ *
+ * e.g.
+ * - `GET /foo` -> `getFoo`
+ * - `POST /bar` -> `postBar`
+ * - `Get /foo/bar` -> `getBarForFoo`
+ * - `GET /foo/{id}` -> `getFooById`
+ * - `GET /foo/{id}/bar` -> `getBarByIdForFoo`
+ * - `GET /foo/{id}/{page}/bar` -> `getBarByPageByIdForFoo`
+ *
+ */
+function createOperationId(method: string, path: string): string {
+  const segments = path.split('/').filter(Boolean);
+  const params = segments.filter(s => s.startsWith('{'));
+  const paths = segments.filter(s => !s.startsWith('{'));
+
+  const paramSuffix = createParamSuffix(params);
+
+  if (paths.length <= 1) {
+    return `${method.toLowerCase()}${toPascal(paths[0] || 'root')}${paramSuffix}`;
+  }
+
+  const [resource, ...context] = paths.reverse();
+  return `${method.toLowerCase()}${toPascal(resource)}${paramSuffix}For${context.reverse().map(toPascal).join('')}`;
+}
+
+export async function generateOperationIds(inputFile: string, opts?: { overwriteExisting: boolean }): Promise<string> {
+  const api = await SwaggerParser.parse(inputFile) as OpenAPI3;
+
+  Object.entries(api.paths || {}).forEach(([path, pathItem]) => {
+    const item = pathItem as PathItemObject;
+    (['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'] as const)
+      .forEach((method) => {
+        const operation = item[method] as OperationObject | undefined;
+        if (operation == null || (operation.operationId != null && !opts?.overwriteExisting)) {
+          return;
+        }
+        operation.operationId = createOperationId(method, path);
+      });
+  });
+
+  const output = JSON.stringify(api, null, 2);
+
+  if (output == null) {
+    throw new Error(`Failed to generate operation IDs for ${inputFile}`);
+  }
+
+  return output;
+}

+ 15 - 0
apps/app/bin/openapi/generate-spec-apiv1.sh

@@ -0,0 +1,15 @@
+# USAGE:
+#   cd apps/app && sh bin/openapi/generate-spec-apiv1.sh
+#   APP_PATH=/path/to/apps/app sh bin/openapi/generate-spec-apiv1.sh
+#   APP_PATH=/path/to/apps/app OUT=/path/to/output sh bin/openapi/generate-spec-apiv1.sh
+
+APP_PATH=${APP_PATH:-"."}
+
+OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv1.json"}
+
+swagger-jsdoc \
+  -o "${OUT}" \
+  -d "${APP_PATH}/bin/openapi/definition-apiv1.js" \
+  "${APP_PATH}/src/server/routes/*.{js,ts}" \
+  "${APP_PATH}/src/server/routes/attachment/**/*.{js,ts}" \
+  "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"

+ 9 - 4
apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh → apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -1,7 +1,7 @@
 # USAGE:
-#   cd apps/app && sh bin/swagger-jsdoc/generate-spec-apiv3.sh
-#   APP_PATH=/path/to/apps/app sh bin/swagger-jsdoc/generate-spec-apiv3.sh
-#   APP_PATH=/path/to/apps/app OUT=/path/to/output sh bin/swagger-jsdoc/generate-spec-apiv3.sh
+#   cd apps/app && sh bin/openapi/generate-spec-apiv3.sh
+#   APP_PATH=/path/to/apps/app sh bin/openapi/generate-spec-apiv3.sh
+#   APP_PATH=/path/to/apps/app OUT=/path/to/output sh bin/openapi/generate-spec-apiv3.sh
 
 APP_PATH=${APP_PATH:-"."}
 
@@ -9,7 +9,7 @@ OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv3.json"}
 
 swagger-jsdoc \
   -o "${OUT}" \
-  -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv3.js" \
+  -d "${APP_PATH}/bin/openapi/definition-apiv3.js" \
   "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/questionnaire/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/templates/server/routes/apiv3/*.ts" \
@@ -17,3 +17,8 @@ swagger-jsdoc \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/login.js" \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
+
+if [ $? -eq 0 ]; then
+  npx tsx "${APP_PATH}/bin/openapi/generate-operation-ids/cli.ts" "${OUT}" --out "${OUT}" --overwrite-existing
+  echo "OpenAPI spec generated and transformed: ${OUT}"
+fi

+ 0 - 15
apps/app/bin/swagger-jsdoc/generate-spec-apiv1.sh

@@ -1,15 +0,0 @@
-# USAGE:
-#   cd apps/app && sh bin/swagger-jsdoc/generate-spec-apiv1.sh
-#   APP_PATH=/path/to/apps/app sh bin/swagger-jsdoc/generate-spec-apiv1.sh
-#   APP_PATH=/path/to/apps/app OUT=/path/to/output sh bin/swagger-jsdoc/generate-spec-apiv1.sh
-
-APP_PATH=${APP_PATH:-"."}
-
-OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv1.json"}
-
-swagger-jsdoc \
-  -o "${OUT}" \
-  -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv1.js" \
-  "${APP_PATH}/src/server/routes/*.{js,ts}" \
-  "${APP_PATH}/src/server/routes/attachment/**/*.{js,ts}" \
-  "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"

+ 12 - 8
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.2.5",
+  "version": "7.2.6-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -27,13 +27,13 @@
     "//// for CI": "",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
-    "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
+    "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
-    "lint:swagger2openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
-    "lint:swagger2openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
+    "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
+    "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint": "run-p lint:**",
-    "prelint:swagger2openapi:apiv3": "pnpm run swagger2openapi:apiv3",
-    "prelint:swagger2openapi:apiv1": "pnpm run swagger2openapi:apiv1",
+    "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
+    "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
     "test:vitest": "vitest run --coverage",
@@ -43,8 +43,9 @@
     "//// misc": "",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
-    "swagger2openapi:apiv3": "sh bin/swagger-jsdoc/generate-spec-apiv3.sh",
-    "swagger2openapi:apiv1": "sh bin/swagger-jsdoc/generate-spec-apiv1.sh",
+    "openapi:build:generate-operation-ids": "vite build -c bin/openapi/generate-operation-ids/vite.config.ts",
+    "openapi:generate-spec:apiv3": "sh bin/openapi/generate-spec-apiv3.sh",
+    "openapi:generate-spec:apiv1": "sh bin/openapi/generate-spec-apiv1.sh",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "version:patch": "pnpm version patch",
     "version:prerelease": "pnpm version prerelease --preid=RC",
@@ -258,6 +259,7 @@
     "mongodb": "mongoose which is used requires mongo@4.16.0."
   },
   "devDependencies": {
+    "@apidevtools/swagger-parser": "^10.1.1",
     "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",
@@ -293,6 +295,7 @@
     "@types/uuid": "^10.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
+    "commander": "^14.0.0",
     "connect-browser-sync": "^2.1.0",
     "diff2html": "^3.4.47",
     "downshift": "^8.2.3",
@@ -315,6 +318,7 @@
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
+    "openapi-typescript": "^7.8.0",
     "pretty-bytes": "^6.1.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dnd": "^14.0.5",

+ 2 - 2
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -21,8 +21,8 @@ test('admin/security is successfully loaded', async({ page }) => {
   await page.goto('/admin/security');
 
   await expect(page.getByTestId('admin-security')).toBeVisible();
-  await expect(page.locator('#isShowRestrictedByOwner')).not.toBeChecked();
-  await expect(page.locator('#isShowRestrictedByGroup')).not.toBeChecked();
+  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText('Always displayed');
+  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText('Always displayed');
 });
 
 test('admin/markdown is successfully loaded', async({ page }) => {

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

@@ -19,7 +19,6 @@
     "readonly_users_access": "Read only users' access",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
-    "displayed_or_hidden": "Hidden / Displayed",
     "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "register_limitation": "Register limitation",
     "register_limitation_desc": "Restriction of new users' registration",

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

@@ -515,6 +515,7 @@
     "accept": "Accept",
     "use_assistant": "Use Assistant",
     "remove_assistant": "Deselect the selected assistant",
+    "text_generation_by_editor_assistant_label": "Editor Assistant is generating text",
     "preset_menu": {
       "summarize": {
         "title": "Summarize this article",

+ 0 - 1
apps/app/public/static/locales/fr_FR/admin.json

@@ -19,7 +19,6 @@
     "readonly_users_access": "Accès des utilisateurs lecture seule",
     "always_hidden": "Toujours caché",
     "always_displayed": "Toujours affiché",
-    "displayed_or_hidden": "Caché / Affiché",
     "Fixed by env var": "Configuré par la variable d'environnement <code>{{key}}={{value}}</code>.",
     "register_limitation": "Paramètres d'inscription",
     "register_limitation_desc": "Restreindre l'inscription de nouveaux utilisateurs",

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

@@ -509,6 +509,7 @@
     "accept": "Accepter",
     "use_assistant": "Utiliser l'assistant",
     "remove_assistant": "Désélectionner l'assistant sélectionné",
+    "text_generation_by_editor_assistant_label": "L'assistant de rédaction génère du texte",
     "preset_menu": {
       "summarize": {
         "title": "Résumer cet article'",

+ 20 - 21
apps/app/public/static/locales/ja_JP/admin.json

@@ -13,22 +13,21 @@
   "Execute": "実行",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
-  "public": "公開",
-  "anyone_with_the_link": "リンクを知っている人のみ",
+  "public": "公開」のページ",
+  "anyone_with_the_link": "リンクを知っている人のみ」のページ",
   "specified_users": "特定ユーザーのみ",
-  "only_me": "自分のみ",
-  "only_inside_the_group": "特定グループのみ",
+  "only_me": "自分のみ」のページ",
+  "only_inside_the_group": "特定グループのみ」のページ",
   "optional": "オプション",
   "days": "日",
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
     "set_point": "設定値",
-    "Guest Users Access":"ゲストユーザーのアクセス",
+    "Guest Users Access": "ゲストユーザーのアクセス",
     "readonly_users_access": "閲覧のみユーザーのアクセス",
-    "always_hidden": "非表示 (固定)",
-    "always_displayed": "表示 (固定)",
-    "displayed_or_hidden": "非表示 / 表示",
+    "always_hidden": "表示しない",
+    "always_displayed": "表示する",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
     "register_limitation": "登録の制限",
     "register_limitation_desc": "新しいユーザーを登録する方法を制限します。",
@@ -73,7 +72,7 @@
     "forced_update_desc": "設定が強制変更されました。前回の設定: ",
     "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
-    "setup_is_not_yet_complete":"セットアップはまだ完了してません",
+    "setup_is_not_yet_complete": "セットアップはまだ完了してません",
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
@@ -107,9 +106,9 @@
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
     "share_link_management": "共有リンク管理",
-    "No_share_links":"共有リンクが存在しません",
-    "share_link_notice":"共有リンクを全て削除します",
-    "delete_all_share_links":"全ての共有リンクを削除します",
+    "No_share_links": "共有リンクが存在しません",
+    "share_link_notice": "共有リンクを全て削除します",
+    "delete_all_share_links": "全ての共有リンクを削除します",
     "share_link_rights": "シェアリンクの権限",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
@@ -512,13 +511,13 @@
       "show_page_side_authors": "作成者・更新者を目次上部に常時表示する",
       "show_page_side_authors_desc": "ページサイドバーの目次上部に作成者と最終更新者の情報を表示します。"
     },
-    "presentation":"プレゼンテーション",
-    "presentation_options":{
+    "presentation": "プレゼンテーション",
+    "presentation_options": {
       "enable_marp": "Marp を有効化する",
       "enable_marp_desc": "プレゼンテーション表示に Marp を利用できるようになります。ただし、XSS に対して脆弱になる恐れがあります。",
       "marp_official_site": "参考:Marp 公式サイト",
       "marp_official_site_link": "https://marp.app",
-      "marp_in_growi" : "参考:GROWI Docs - Marp でスライドを作成する",
+      "marp_in_growi": "参考:GROWI Docs - Marp でスライドを作成する",
       "marp_in_growi_link": "https://docs.growi.org/ja/guide/features/marp.html"
     },
     "custom_title": "カスタム Title",
@@ -532,7 +531,7 @@
     "write_css": " システム全体に適用されるCSSを記述できます。",
     "ctrl_space": "Ctrl+Space でコード補完",
     "custom_script": "カスタムスクリプト",
-    "custom_presentation":"プレゼンテーション",
+    "custom_presentation": "プレゼンテーション",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",
     "custom_logo": "カスタムロゴ",
@@ -541,7 +540,7 @@
     "current_logo": "現在のロゴ",
     "upload_new_logo": "新しいロゴをアップロードする",
     "delete_logo": "ロゴを削除"
-   },
+  },
   "importer_management": {
     "import_data": "データインポート",
     "article": "記事",
@@ -681,7 +680,7 @@
     "delete": "削除",
     "integration_procedure": "連携手順",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 設定",
-    "integration_failed":"連携に失敗しました",
+    "integration_failed": "連携に失敗しました",
     "reset": "リセット",
     "reset_all_settings": "全ての設定をリセット",
     "delete_slackbot_settings": "Slack Bot 設定を削除する",
@@ -728,7 +727,7 @@
       "allow_specified_long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
-      "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",
+      "test_connection_only_public_channel": "連携テストは public チャンネルで確認してください",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
       "send_message_to_slack_work_space": "Slack ワークスペースに送信しました",
       "add_slack_workspace": "Slackワークスペースを追加"
@@ -752,7 +751,7 @@
     }
   },
   "slack_integration_legacy": {
-    "slack_integration_legacy":  "Slack連携 (レガシー)",
+    "slack_integration_legacy": "Slack連携 (レガシー)",
     "alert_disabled": "<a href='/admin/slack-integration'>新しい設定</a>が有効になっているため、この 'Slack連携 (レガシー)' は現在無効になっています。",
     "alert_deplicated": "この 'Slack連携 (レガシー)' は将来廃止されます。代わりに<a href='/admin/slack-integration'>新しいSlack連携機能</a>を利用してください。"
   },
@@ -989,7 +988,7 @@
     "ADMIN_SITE_URL_UPDATE": "サイトURL設定の更新",
     "ADMIN_MAIL_SMTP_UPDATE": "メール設定(SMTP)の更新",
     "ADMIN_MAIL_SES_UPDATE": "メール設定(SES)の更新",
-    "ADMIN_MAIL_TEST_SUBMIT" : "テストメールの送信",
+    "ADMIN_MAIL_TEST_SUBMIT": "テストメールの送信",
     "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "ファイルアップロード設定の更新",
     "ADMIN_PLUGIN_UPDATE": "プラグイン設定の更新",
     "ADMIN_MAINTENANCEMODE_ENABLED": "メンテナンスモードの開始",

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

@@ -547,6 +547,7 @@
     "accept": "採用",
     "use_assistant": "アシスタントを使用する",
     "remove_assistant": "選択されているアシスタントの解除",
+    "text_generation_by_editor_assistant_label": "エディターアシスタントが文章を生成中",
     "preset_menu": {
       "summarize": {
         "title": "この記事の要約をつくる",

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

@@ -26,7 +26,6 @@
     "set_point": "设定值",
     "always_displayed": "始终显示",
     "always_hidden": "总是隐藏",
-    "displayed_or_hidden": "隐藏 / 显示",
     "Guest Users Access": "来宾用户访问",
     "readonly_users_access": "只浏览用户的访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",

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

@@ -504,6 +504,7 @@
     "accept": "接受",
     "use_assistant": "使用助手",
     "remove_assistant": "取消选定的助手",
+    "text_generation_by_editor_assistant_label": "编辑助理正在生成文本",
     "preset_menu": {
       "summarize": {
         "title": "为此文章创建摘要",

+ 94 - 52
apps/app/src/client/components/Admin/Security/SecuritySetting.jsx

@@ -297,7 +297,7 @@ class SecuritySetting extends React.Component {
                     onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                   >
                     <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'rotate-90' : ''}`}>navigate_next</span>
-                    { t('security_settings.other_options') }
+                    {t('security_settings.other_options')}
                   </button>
                   <Collapse isOpen={expantDeleteOptionsState}>
                     <div className="pb-4">
@@ -308,7 +308,7 @@ class SecuritySetting extends React.Component {
                           <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
                         </span>
                       </p>
-                      { this.previousPageRecursiveAuthorityState(deletionType) !== null && (
+                      {this.previousPageRecursiveAuthorityState(deletionType) !== null && (
                         <div className="mb-3">
                           <strong>
                             {t('security_settings.forced_update_desc')}
@@ -356,60 +356,102 @@ class SecuritySetting extends React.Component {
           </div>
         )}
 
-        <h4 className="mt-4">{ t('security_settings.page_list_and_search_results') }</h4>
-        <div className="row justify-content-md-center">
-          <table className="table table-bordered col-lg-9 mb-5">
-            <thead>
-              <tr>
-                <th scope="col">{ t('security_settings.scope_of_page_disclosure') }</th>
-                <th scope="col">{ t('security_settings.set_point') }</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th scope="row">{ t('public') }</th>
-                <td><span className="material-symbols-outlined text-success me-1">check_circle</span>{ t('security_settings.always_displayed') }</td>
-              </tr>
-              <tr>
-                <th scope="row">{ t('anyone_with_the_link') }</th>
-                <td><span className="material-symbols-outlined text-danger me-1">cancel</span>{ t('security_settings.always_hidden') }</td>
-              </tr>
-              <tr>
-                <th scope="row">{ t('only_me') }</th>
-                <td>
-                  <div className="form-check form-switch form-check-success">
-                    <input
-                      type="checkbox"
-                      className="form-check-input"
+        <h4 className="alert-anchor border-bottom mt-4">{t('security_settings.page_list_and_search_results')}</h4>
+        <div className="row mb-4">
+          <div className="col-md-10">
+            <div className="row">
+
+              {/* Left Column: Labels */}
+              <div className="col-5 d-flex flex-column align-items-end p-4">
+                <div className="fw-bold mb-4">{t('public')}</div>
+                <div className="fw-bold mb-4">{t('anyone_with_the_link')}</div>
+                <div className="fw-bold mb-4">{t('only_me')}</div>
+                <div className="fw-bold">{t('only_inside_the_group')}</div>
+              </div>
+
+              {/* Right Column: Content */}
+              <div className="col-7 d-flex flex-column align-items-start pt-4 pb-4">
+                <div className="mb-4 d-flex align-items-center">
+                  <span className="material-symbols-outlined text-success me-1"></span>
+                  {t('security_settings.always_displayed')}
+                </div>
+                <div className="mb-3 d-flex align-items-center">
+                  <span className="material-symbols-outlined text-danger me-1"></span>
+                  {t('security_settings.always_hidden')}
+                </div>
+
+                {/* Owner Restriction Dropdown */}
+                <div className="mb-3">
+                  <div className="dropdown">
+                    <button
+                      className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
+                      type="button"
                       id="isShowRestrictedByOwner"
-                      checked={!adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
-                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
-                    />
-                    <label className="form-label form-check-label" htmlFor="isShowRestrictedByOwner">
-                      {t('security_settings.displayed_or_hidden')}
-                    </label>
+                      data-bs-toggle="dropdown"
+                      aria-haspopup="true"
+                      aria-expanded="true"
+                    >
+                      <span className="float-start">
+                        {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Displayed' && t('security_settings.always_displayed')}
+                        {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden' && t('security_settings.always_hidden')}
+                      </span>
+                    </button>
+                    <div className="dropdown-menu" aria-labelledby="isShowRestrictedByOwner">
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Displayed') }}
+                      >
+                        {t('security_settings.always_displayed')}
+                      </button>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Hidden') }}
+                      >
+                        {t('security_settings.always_hidden')}
+                      </button>
+                    </div>
                   </div>
-                </td>
-              </tr>
-              <tr>
-                <th scope="row">{ t('only_inside_the_group') }</th>
-                <td>
-                  <div className="form-check form-switch form-check-success">
-                    <input
-                      type="checkbox"
-                      className="form-check-input"
+                </div>
+
+                {/* Group Restriction Dropdown */}
+                <div className="">
+                  <div className="dropdown">
+                    <button
+                      className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
+                      type="button"
                       id="isShowRestrictedByGroup"
-                      checked={!adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
-                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
-                    />
-                    <label className="form-label form-check-label" htmlFor="isShowRestrictedByGroup">
-                      {t('security_settings.displayed_or_hidden')}
-                    </label>
+                      data-bs-toggle="dropdown"
+                      aria-haspopup="true"
+                      aria-expanded="true"
+                    >
+                      <span className="float-start">
+                        {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Displayed' && t('security_settings.always_displayed')}
+                        {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden' && t('security_settings.always_hidden')}
+                      </span>
+                    </button>
+                    <div className="dropdown-menu" aria-labelledby="isShowRestrictedByGroup">
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Displayed') }}
+                      >
+                        {t('security_settings.always_displayed')}
+                      </button>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Hidden') }}
+                      >
+                        {t('security_settings.always_hidden')}
+                      </button>
+                    </div>
                   </div>
-                </td>
-              </tr>
-            </tbody>
-          </table>
+                </div>
+              </div>
+            </div>
+          </div>
         </div>
 
         <h4 className="mb-3">{t('security_settings.page_access_rights')}</h4>

+ 26 - 3
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -8,6 +8,7 @@ import type {
 import {
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
@@ -17,7 +18,9 @@ import {
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
+import {
+  useIsGuestUser, useIsReadOnlyUser, useIsSearchPage, useIsUsersHomepageDeletionEnabled,
+} from '~/stores-universal/context';
 import {
   EditorMode, useEditorMode,
 } from '~/stores-universal/ui';
@@ -27,7 +30,7 @@ import {
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-import { useSWRxPageInfo, useSWRxTagsInfo } from '../../../stores/page';
+import { useSWRxPageInfo, useSWRxTagsInfo, useCurrentPagePath } from '../../../stores/page';
 import { useSWRxUsersList } from '../../../stores/user';
 import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import {
@@ -134,6 +137,10 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { data: editorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
+  const { data: isUsersHomepageDeletionEnabled } = useIsUsersHomepageDeletionEnabled();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const isUsersHomepage = currentPagePath == null ? false : pagePathUtils.isUsersHomepage(currentPagePath);
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -249,6 +256,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
   }, [expandContentWidth, isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
 
+  const isEnableActions = useMemo(() => {
+    if (isGuestUser) {
+      return false;
+    }
+
+    if (currentPagePath == null) {
+      return false;
+    }
+
+    if (isUsersHomepage && !isUsersHomepageDeletionEnabled) {
+      return false;
+    }
+
+    return true;
+  }, [isGuestUser, isUsersHomepage, isUsersHomepageDeletionEnabled]);
+
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
       return undefined;
@@ -332,7 +355,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
-          isEnableActions={!isGuestUser}
+          isEnableActions={isEnableActions}
           isReadOnlyUser={!!isReadOnlyUser}
           forceHideMenuItems={forceHideMenuItemsWithAdditions}
           additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}

+ 5 - 3
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx

@@ -1,8 +1,8 @@
 import type { JSX } from 'react';
 
-import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 
+import { useIsAiEnabled } from '~/stores-universal/context';
 import { useDrawerOpened } from '~/stores/ui';
 
 import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
@@ -16,7 +16,7 @@ const SavePageControls = dynamic(() => import('./SavePageControls').then(mod =>
 const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
 export const EditorNavbarBottom = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { data: isAiEnabled } = useIsAiEnabled();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
 
   return (
@@ -31,7 +31,9 @@ export const EditorNavbarBottom = (): JSX.Element => {
         </a>
         <form className="me-auto d-flex gap-2">
           <OptionsSelector />
-          <EditorAssistantToggleButton />
+          {isAiEnabled && (
+            <EditorAssistantToggleButton />
+          )}
         </form>
         <form>
           <SavePageControls />

+ 23 - 19
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -32,13 +32,14 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      currentGroupRestrictionDisplayMode: 'Hidden',
+      currentOwnerRestrictionDisplayMode: 'Hidden',
       isAllGroupMembershipRequiredForPageCompleteDeletion: true,
       previousPageRecursiveDeletionAuthority: null,
       previousPageRecursiveCompleteDeletionAuthority: null,
       expandOtherOptionsForDeletion: false,
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
-      isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
       isForceDeleteUserHomepageOnUserDeletion: false,
       isRomUserAllowedToComment: false,
@@ -56,6 +57,8 @@ export default class AdminGeneralSecurityContainer extends Container {
       shareLinksActivePage: 1,
     };
 
+    this.changeOwnerRestrictionDisplayMode = this.changeOwnerRestrictionDisplayMode.bind(this);
+    this.changeGroupRestrictionDisplayMode = this.changeGroupRestrictionDisplayMode.bind(this);
     this.changePageDeletionAuthority = this.changePageDeletionAuthority.bind(this);
     this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
     this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
@@ -76,8 +79,9 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
-      isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
+      // Set display to 'Hidden' if hideRestrictedByOwner is anything but false.
+      currentOwnerRestrictionDisplayMode: generalSetting.hideRestrictedByOwner === false ? 'Displayed' : 'Hidden',
+      currentGroupRestrictionDisplayMode: generalSetting.hideRestrictedByGroup === false ? 'Displayed' : 'Hidden',
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
@@ -123,6 +127,20 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ disableLinkSharing });
   }
 
+  /**
+   * Change ownerRestrictionDisplayMode
+   */
+  changeOwnerRestrictionDisplayMode(mode) {
+    this.setState({ currentOwnerRestrictionDisplayMode: mode });
+  }
+
+  /**
+   * Change groupRestrictionDisplayMode
+   */
+  changeGroupRestrictionDisplayMode(mode) {
+    this.setState({ currentGroupRestrictionDisplayMode: mode });
+  }
+
   /**
    * Change restrictGuestMode
    */
@@ -194,20 +212,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ expandOtherOptionsForCompleteDeletion: bool });
   }
 
-  /**
-   * Switch showRestrictedByOwner
-   */
-  switchIsShowRestrictedByOwner() {
-    this.setState({ isShowRestrictedByOwner:  !this.state.isShowRestrictedByOwner });
-  }
-
-  /**
-   * Switch showRestrictedByGroup
-   */
-  switchIsShowRestrictedByGroup() {
-    this.setState({ isShowRestrictedByGroup:  !this.state.isShowRestrictedByGroup });
-  }
-
   /**
    * Switch isUsersHomepageDeletionEnabled
    */
@@ -245,8 +249,8 @@ export default class AdminGeneralSecurityContainer extends Container {
       pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
-      hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
+      hideRestrictedByGroup: this.state.currentGroupRestrictionDisplayMode === 'Hidden',
+      hideRestrictedByOwner: this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
       isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,

+ 0 - 15
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -102,7 +102,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: listExternalUserGroups
    *         summary: /external-user-groups
    *         parameters:
    *           - name: page
@@ -172,7 +171,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getAncestors
    *         summary: /external-user-groups/ancestors
    *         parameters:
    *           - name: groupId
@@ -217,7 +215,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: listChildren
    *         summary: /external-user-groups/children
    *         parameters:
    *           - name: parentIds
@@ -274,7 +271,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getExternalUserGroup
    *         summary: /external-user-groups/{id}
    *         parameters:
    *           - name: id
@@ -316,7 +312,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: deleteExternalUserGroup
    *         summary: /external-user-groups/{id}
    *         parameters:
    *           - name: id
@@ -391,7 +386,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: updateExternalUserGroup
    *         summary: /external-user-groups/{id}
    *         parameters:
    *           - name: id
@@ -449,7 +443,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getExternalUserGroupRelations
    *         summary: /external-user-groups/{id}/external-user-group-relations
    *         parameters:
    *           - name: id
@@ -496,7 +489,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getLdapSyncSettings
    *         summary: Get LDAP sync settings
    *         responses:
    *           200:
@@ -546,7 +538,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getKeycloakSyncSettings
    *         summary: Get Keycloak sync settings
    *         responses:
    *           200:
@@ -596,7 +587,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: updateLdapSyncSettings
    *         summary: Update LDAP sync settings
    *         requestBody:
    *           required: true
@@ -673,7 +663,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: updateKeycloakSyncSettings
    *         summary: /external-user-groups/keycloak/sync-settings
    *         requestBody:
    *           required: true
@@ -746,7 +735,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: syncExternalUserGroupsLdap
    *         summary: Start LDAP sync process
    *         responses:
    *           202:
@@ -793,7 +781,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: syncExternalUserGroupsKeycloak
    *         summary: /external-user-groups/keycloak/sync
    *         responses:
    *           202:
@@ -856,7 +843,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getExternalUserGroupsLdapSyncStatus
    *         summary: Get LDAP sync status
    *         responses:
    *           200:
@@ -879,7 +865,6 @@ module.exports = (crowi: Crowi): Router => {
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
-   *         operationId: getExternalUserGroupsLdapSyncStatus
    *         summary: /external-user-groups/ldap/sync-status
    *         responses:
    *           200:

+ 129 - 116
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -1,6 +1,6 @@
 import type { KeyboardEvent, JSX } from 'react';
 import {
-  type FC, memo, useRef, useEffect, useState, useCallback, useMemo,
+  type FC, memo, useEffect, useState, useCallback, useMemo,
 } from 'react';
 
 import { Controller } from 'react-hook-form';
@@ -29,7 +29,7 @@ import {
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRxThreads } from '../../../stores/thread';
 
-import { MessageCard, type MessageCardRole } from './MessageCard';
+import { MessageCard } from './MessageCard/MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantSidebar.module.scss';
@@ -78,7 +78,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
     // Views
     initialView: initialViewForKnowledgeAssistant,
-    generateMessageCard: generateMessageCardForKnowledgeAssistant,
     generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
@@ -95,7 +94,8 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
     // Views
     generateInitialView: generateInitialViewForEditorAssistant,
-    generateMessageCard: generateMessageCardForEditorAssistant,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon: headerIconForEditorAssistant,
     headerText: headerTextForEditorAssistant,
     placeHolder: placeHolderForEditorAssistant,
@@ -354,18 +354,25 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     return initialViewForKnowledgeAssistant;
   }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
 
-  const messageCard = useCallback(
-    (role: MessageCardRole, children: string, messageId?: string, messageLogs?: MessageLog[], generatingAnswerMessage?: MessageLog) => {
-      if (isEditorAssistant) {
-        if (messageId == null || messageLogs == null) {
-          return <></>;
-        }
-        return generateMessageCardForEditorAssistant(role, children, messageId, messageLogs, generatingAnswerMessage);
+  const messageCardAdditionalItemForGeneratingMessage = useMemo(() => {
+    if (isEditorAssistant) {
+      return generatingEditorTextLabel;
+    }
+
+    return <></>;
+  }, [generatingEditorTextLabel, isEditorAssistant]);
+
+
+  const messageCardAdditionalItemForGeneratedMessage = useCallback((messageId?: string) => {
+    if (isEditorAssistant) {
+      if (messageId == null || messageLogs == null) {
+        return <></>;
       }
+      return generateActionButtons(messageId, messageLogs, generatingAnswerMessage);
+    }
 
-      return generateMessageCardForKnowledgeAssistant(role, children);
-    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
-  );
+    return undefined;
+  }, [generateActionButtons, generatingAnswerMessage, isEditorAssistant, messageLogs]);
 
   return (
     <>
@@ -383,96 +390,112 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
             <span className="material-symbols-outlined">close</span>
           </button>
         </div>
-        <div className="p-4 d-flex flex-column gap-4 vh-100">
-
-          { threadData != null
-            ? (
-              <div className="vstack gap-4 pb-2">
-                { messageLogs.map(message => (
-                  <>
-                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
-                  </>
-                )) }
-                { generatingAnswerMessage != null && (
-                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
-                )}
-                { messageLogs.length > 0 && (
-                  <div className="d-flex justify-content-center">
-                    <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                      {t('sidebar_ai_assistant.caution_against_hallucination')}
-                    </span>
+
+        <div className="flex-grow-1 overflow-hidden">
+          <SimpleBar
+            className="h-100"
+            autoHide
+          >
+            <div className="p-4 d-flex flex-column gap-4 flex-grow-1">
+              { threadData != null
+                ? (
+                  <div className="vstack gap-4 pb-2">
+                    { messageLogs.map(message => (
+                      <>
+                        <MessageCard
+                          role={message.isUserMessage ? 'user' : 'assistant'}
+                          additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
+                        >
+                          {message.content}
+                        </MessageCard>
+                      </>
+                    )) }
+                    { generatingAnswerMessage != null && (
+                      <MessageCard
+                        role="assistant"
+                        additionalItem={messageCardAdditionalItemForGeneratingMessage}
+                      >
+                        {generatingAnswerMessage.content}
+                      </MessageCard>
+                    )}
+                    { messageLogs.length > 0 && (
+                      <div className="d-flex justify-content-center">
+                        <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                          {t('sidebar_ai_assistant.caution_against_hallucination')}
+                        </span>
+                      </div>
+                    )}
                   </div>
-                )}
-              </div>
-            )
-            : (
-              <>{ initialView }</>
-            )
-          }
+                )
+                : (
+                  <>{ initialView }</>
+                )
+              }
+            </div>
+          </SimpleBar>
+        </div>
 
-          <div className="mt-auto">
-            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
-              <Controller
-                name="input"
-                control={form.control}
-                render={({ field }) => (
-                  <ResizableTextarea
-                    {...field}
-                    required
-                    className="form-control textarea-ask"
-                    style={{ resize: 'none' }}
-                    rows={1}
-                    placeholder={placeHolder}
-                    onKeyDown={keyDownHandler}
-                    disabled={form.formState.isSubmitting}
-                  />
-                )}
-              />
-              <div className="flex-fill hstack gap-2 justify-content-between m-0">
-                { !isEditorAssistant && generateModeSwitchesDropdownForKnowledgeAssistant(isGenerating) }
-                { isEditorAssistant && <div /> }
-                <button
-                  type="submit"
-                  className="btn btn-submit no-border"
-                  disabled={form.formState.isSubmitting || isGenerating}
-                >
-                  <span className="material-symbols-outlined">send</span>
-                </button>
+        <div className="position-sticky bottom-0 bg-body z-2 p-3 border-top">
+          <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
+            <Controller
+              name="input"
+              control={form.control}
+              render={({ field }) => (
+                <ResizableTextarea
+                  {...field}
+                  required
+                  className="form-control textarea-ask"
+                  style={{ resize: 'none' }}
+                  rows={1}
+                  placeholder={placeHolder}
+                  onKeyDown={keyDownHandler}
+                  disabled={form.formState.isSubmitting}
+                />
+              )}
+            />
+            <div className="flex-fill hstack gap-2 justify-content-between m-0">
+              { !isEditorAssistant && generateModeSwitchesDropdownForKnowledgeAssistant(isGenerating) }
+              { isEditorAssistant && <div /> }
+              <button
+                type="submit"
+                className="btn btn-submit no-border"
+                disabled={form.formState.isSubmitting || isGenerating}
+              >
+                <span className="material-symbols-outlined">send</span>
+              </button>
+            </div>
+          </form>
+
+          {form.formState.errors.input != null && (
+            <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+              <div>
+                <span className="material-symbols-outlined text-danger me-2">error</span>
+                <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_ai_assistant.error_message') }</span>
               </div>
-            </form>
-
-            {form.formState.errors.input != null && (
-              <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
-                <div>
-                  <span className="material-symbols-outlined text-danger me-2">error</span>
-                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_ai_assistant.error_message') }</span>
-                </div>
 
-                <button
-                  type="button"
-                  className="btn btn-link text-body-secondary p-0"
-                  aria-expanded={isErrorDetailCollapsed}
-                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
-                >
-                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
-                    chevron_right
-                  </span>
-                  <span className="small">{t('sidebar_ai_assistant.show_error_detail')}</span>
-                </button>
-
-                <Collapse isOpen={isErrorDetailCollapsed}>
-                  <div className="ms-2">
-                    <div className="">
-                      <div className="text-body-secondary small">
-                        {form.formState.errors.input?.message}
-                      </div>
+              <button
+                type="button"
+                className="btn btn-link text-body-secondary p-0"
+                aria-expanded={isErrorDetailCollapsed}
+                onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+              >
+                <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                  chevron_right
+                </span>
+                <span className="small">{t('sidebar_ai_assistant.show_error_detail')}</span>
+              </button>
+
+              <Collapse isOpen={isErrorDetailCollapsed}>
+                <div className="ms-2">
+                  <div className="">
+                    <div className="text-body-secondary small">
+                      {form.formState.errors.input?.message}
                     </div>
                   </div>
-                </Collapse>
-              </div>
-            )}
-
-          </div>
+                </div>
+              </Collapse>
+            </div>
+          )}
         </div>
       </div>
     </>
@@ -481,9 +504,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
 
 export const AiAssistantSidebar: FC = memo((): JSX.Element => {
-  const sidebarRef = useRef<HTMLDivElement>(null);
-  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
-
   const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar, refreshThreadData } = useAiAssistantSidebar();
   const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
@@ -522,24 +542,17 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
 
   return (
     <div
-      ref={sidebarRef}
       className={`position-fixed top-0 end-0 h-100 border-start bg-body shadow-sm overflow-hidden ${moduleClass}`}
       data-testid="grw-right-sidebar"
     >
-      <SimpleBar
-        scrollableNodeProps={{ ref: sidebarScrollerRef }}
-        className="h-100 position-relative"
-        autoHide
-      >
-        <AiAssistantSidebarSubstance
-          isEditorAssistant={isEditorAssistant}
-          threadData={threadData}
-          aiAssistantData={aiAssistantData}
-          onMessageReceived={mutateThreads}
-          onNewThreadCreated={newThreadCreatedHandler}
-          onCloseButtonClicked={closeAiAssistantSidebar}
-        />
-      </SimpleBar>
+      <AiAssistantSidebarSubstance
+        isEditorAssistant={isEditorAssistant}
+        threadData={threadData}
+        aiAssistantData={aiAssistantData}
+        onMessageReceived={mutateThreads}
+        onNewThreadCreated={newThreadCreatedHandler}
+        onCloseButtonClicked={closeAiAssistantSidebar}
+      />
     </div>
   );
 });

+ 0 - 126
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx

@@ -1,126 +0,0 @@
-import { useCallback, useState, type JSX } from 'react';
-
-import type { LinkProps } from 'next/link';
-import { useTranslation } from 'react-i18next';
-import ReactMarkdown from 'react-markdown';
-
-import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
-
-import styles from './MessageCard.module.scss';
-
-const moduleClass = styles['message-card'] ?? '';
-
-
-const userMessageCardModuleClass = styles['user-message-card'] ?? '';
-
-const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
-  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
-    <div className="card-body">
-      <ReactMarkdown>{children}</ReactMarkdown>
-    </div>
-  </div>
-);
-
-
-const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
-
-const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  return (
-    <NextLink href={props.href} className="link-primary">
-      {props.children}
-    </NextLink>
-  );
-};
-
-const AssistantMessageCard = ({
-  children, showActionButtons, onAccept, onDiscard,
-}: {
-  children: string,
-  showActionButtons?: boolean
-  onAccept?: () => void,
-  onDiscard?: () => void,
-}): JSX.Element => {
-  const { t } = useTranslation();
-
-  const [isActionButtonClicked, setIsActionButtonClicked] = useState(false);
-
-  const clickActionButtonHandler = useCallback((action: 'accept' | 'discard') => {
-    setIsActionButtonClicked(true);
-    if (action === 'accept') {
-      onAccept?.();
-      return;
-    }
-
-    onDiscard?.();
-  }, [onAccept, onDiscard]);
-
-  return (
-    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
-      <div className="card-body d-flex">
-        <div className="me-2 me-lg-3">
-          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
-        </div>
-        <div>
-          { children.length > 0
-            ? (
-              <>
-                <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
-
-                {showActionButtons && !isActionButtonClicked && (
-                  <div className="d-flex mt-2 justify-content-start">
-                    <button
-                      type="button"
-                      className="btn btn-outline-secondary me-2"
-                      onClick={() => clickActionButtonHandler('discard')}
-                    >
-                      {t('sidebar_ai_assistant.discard')}
-                    </button>
-                    <button
-                      type="button"
-                      className="btn btn-success"
-                      onClick={() => clickActionButtonHandler('accept')}
-                    >
-                      {t('sidebar_ai_assistant.accept')}
-                    </button>
-                  </div>
-                )}
-              </>
-            )
-            : (
-              <span className="text-thinking">
-                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
-              </span>
-            )
-          }
-        </div>
-      </div>
-    </div>
-  );
-};
-
-export type MessageCardRole = 'user' | 'assistant';
-
-type Props = {
-  role: MessageCardRole,
-  children: string,
-  showActionButtons?: boolean,
-  onDiscard?: () => void,
-  onAccept?: () => void,
-}
-
-export const MessageCard = (props: Props): JSX.Element => {
-  const {
-    role, children, showActionButtons, onAccept, onDiscard,
-  } = props;
-
-  return role === 'user'
-    ? <UserMessageCard>{children}</UserMessageCard>
-    : (
-      <AssistantMessageCard
-        showActionButtons={showActionButtons}
-        onAccept={onAccept}
-        onDiscard={onDiscard}
-      >{children}
-      </AssistantMessageCard>
-    );
-};

+ 0 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss


+ 95 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx

@@ -0,0 +1,95 @@
+import { type JSX } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import ReactMarkdown from 'react-markdown';
+
+import { Header } from './ReactMarkdownComponents/Header';
+import { NextLinkWrapper } from './ReactMarkdownComponents/NextLinkWrapper';
+
+import styles from './MessageCard.module.scss';
+
+const moduleClass = styles['message-card'] ?? '';
+
+
+const userMessageCardModuleClass = styles['user-message-card'] ?? '';
+
+const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
+  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
+    <div className="card-body">
+      <ReactMarkdown>{children}</ReactMarkdown>
+    </div>
+  </div>
+);
+
+
+const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
+
+
+const AssistantMessageCard = ({
+  children,
+  additionalItem,
+}: {
+  children: string,
+  additionalItem?: JSX.Element,
+}): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
+      <div className="card-body d-flex">
+        <div className="me-2 me-lg-3">
+          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
+        </div>
+        <div>
+          { children.length > 0
+            ? (
+              <>
+                <ReactMarkdown components={{
+                  a: NextLinkWrapper,
+                  h1: ({ children }) => <Header level={1}>{children}</Header>,
+                  h2: ({ children }) => <Header level={2}>{children}</Header>,
+                  h3: ({ children }) => <Header level={3}>{children}</Header>,
+                  h4: ({ children }) => <Header level={4}>{children}</Header>,
+                  h5: ({ children }) => <Header level={5}>{children}</Header>,
+                  h6: ({ children }) => <Header level={6}>{children}</Header>,
+                }}
+                >{children}
+                </ReactMarkdown>
+                { additionalItem }
+              </>
+            )
+            : (
+              <span className="text-thinking">
+                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+              </span>
+            )
+          }
+        </div>
+      </div>
+    </div>
+  );
+};
+
+
+type MessageCardRole = 'user' | 'assistant';
+
+type Props = {
+  role: MessageCardRole,
+  children: string,
+  additionalItem?: JSX.Element,
+}
+
+export const MessageCard = (props: Props): JSX.Element => {
+  const {
+    role, children, additionalItem,
+  } = props;
+
+  return role === 'user'
+    ? <UserMessageCard>{children}</UserMessageCard>
+    : (
+      <AssistantMessageCard
+        additionalItem={additionalItem}
+      >{children}
+      </AssistantMessageCard>
+    );
+};

+ 25 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx

@@ -0,0 +1,25 @@
+type Level = 1 | 2 | 3 | 4 | 5 | 6;
+
+const fontSizes: Record<Level, string> = {
+  1: '1.5rem',
+  2: '1.25rem',
+  3: '1rem',
+  4: '0.875rem',
+  5: '0.75rem',
+  6: '0.625rem',
+};
+
+export const Header = ({ children, level }: { children: React.ReactNode, level: Level}): JSX.Element => {
+  const Tag = `h${level}` as keyof JSX.IntrinsicElements;
+
+  return (
+    <Tag
+      style={{
+        fontSize: fontSizes[level],
+        lineHeight: 1.4,
+      }}
+    >
+      {children}
+    </Tag>
+  );
+};

+ 13 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+import type { LinkProps } from 'next/link';
+
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+export const NextLinkWrapper = (props: LinkProps & {children: React.ReactNode, href: string}): JSX.Element => {
+  return (
+    <NextLink href={props.href} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};

+ 84 - 56
apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -1,5 +1,5 @@
 import {
-  useCallback, useEffect, useState, useRef, useMemo,
+  useCallback, useEffect, useState, useRef, useMemo, type FC,
 } from 'react';
 
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
@@ -18,16 +18,12 @@ import {
   SseDetectedDiffSchema,
   SseFinalizedSchema,
   isReplaceDiff,
-  // isInsertDiff,
-  // isDeleteDiff,
-  // isRetainDiff,
   type SseMessage,
   type SseDetectedDiff,
   type SseFinalized,
 } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
-import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useCurrentPageId } from '~/stores/page';
 
 import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
@@ -35,8 +31,6 @@ import type { MessageLog } from '../../interfaces/message';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
-// import { type FormData } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar';
-import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
 import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 
@@ -57,8 +51,8 @@ interface ProcessMessage {
 interface GenerateInitialView {
   (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
 }
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+interface GenerateActionButtons {
+  (messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
 }
 export interface FormData {
   input: string,
@@ -78,10 +72,12 @@ type UseEditorAssistant = () => {
   form: UseFormReturn<FormData>
   resetForm: () => void
   isTextSelected: boolean,
+  isGeneratingEditorText: boolean,
 
   // Views
   generateInitialView: GenerateInitialView,
-  generateMessageCard: GenerateMessageCard,
+  generatingEditorTextLabel?: JSX.Element,
+  generateActionButtons: GenerateActionButtons,
   headerIcon: JSX.Element,
   headerText: JSX.Element,
   placeHolder: string,
@@ -142,13 +138,14 @@ const getLineInfo = (yText: YText, lineNumber: number): { text: string, startInd
 
 export const useEditorAssistant: UseEditorAssistant = () => {
   // Refs
-  // const positionRef = useRef<number>(0);
   const lineRef = useRef<number>(0);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   // States
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
+  const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
 
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
 
@@ -194,6 +191,9 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       }
     };
 
+    // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
+    mutateIsEnableUnifiedMergeView(false);
+
     const response = await fetch('/_api/v3/openai/edit', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
@@ -205,13 +205,33 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     });
 
     return response;
-  }, [codeMirrorEditor, selectedText]);
+  }, [codeMirrorEditor, mutateIsEnableUnifiedMergeView, selectedText]);
 
   const processMessage: ProcessMessage = useCallback((data, handler) => {
+    // Reset timer whenever data is received
+    const handleDataReceived = () => {
+    // Clear existing timer
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+      }
+
+      // Hide spinner since data is flowing
+      if (isGeneratingEditorText) {
+        setIsGeneratingEditorText(false);
+      }
+
+      // Set new timer
+      timerRef.current = setTimeout(() => {
+        setIsGeneratingEditorText(true);
+      }, 500);
+    };
+
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handleDataReceived();
       handler.onMessage(data);
     });
     handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      handleDataReceived();
       mutateIsEnableUnifiedMergeView(true);
       setDetectedDiff((prev) => {
         const newData = { data, applied: false, id: crypto.randomUUID() };
@@ -225,39 +245,20 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
       handler.onFinalized(data);
     });
-  }, [mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, mutateIsEnableUnifiedMergeView]);
 
   const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
     setSelectedText(selectedText);
     lineRef.current = selectedTextFirstLineNumber;
   }, []);
 
+
   // Effects
   useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
 
   useEffect(() => {
     const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
     if (yDocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
-
-      // For debug
-      // const testDetectedDiff = [
-      //   {
-      //     data: { diff: { retain: 9 } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      //   {
-      //     data: { diff: { delete: 5 } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      //   {
-      //     data: { diff: { insert: 'growi' } },
-      //     applied: false,
-      //     id: crypto.randomUUID(),
-      //   },
-      // ];
-
       const yText = yDocs.secondaryDoc.getText('codemirror');
       yDocs.secondaryDoc.transact(() => {
         pendingDetectedDiff.forEach((detectedDiff) => {
@@ -276,15 +277,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
               appendTextLastLine(yText, detectedDiff.data.diff.replace);
             }
           }
-          // if (isInsertDiff(detectedDiff.data)) {
-          //   yText.insert(positionRef.current, detectedDiff.data.diff.insert);
-          // }
-          // if (isDeleteDiff(detectedDiff.data)) {
-          //   yText.delete(positionRef.current, detectedDiff.data.diff.delete);
-          // }
-          // if (isRetainDiff(detectedDiff.data)) {
-          //   positionRef.current += detectedDiff.data.diff.retain;
-          // }
         });
       });
 
@@ -308,10 +300,18 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       setSelectedText(undefined);
       setDetectedDiff(undefined);
       lineRef.current = 0;
-      // positionRef.current = 0;
     }
   }, [detectedDiff]);
 
+  useEffect(() => {
+    return () => {
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
+    };
+  }, []);
+
 
   // Views
   const headerIcon = useMemo(() => {
@@ -348,13 +348,16 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     );
   }, [selectedAiAssistant]);
 
-
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+  const generateActionButtons: GenerateActionButtons = useCallback((messageId, messageLogs, generatingAnswerMessage) => {
     const isActionButtonShown = (() => {
       if (!aiAssistantSidebarData?.isEditorAssistant) {
         return false;
       }
 
+      if (!isEnableUnifiedMergeView) {
+        return false;
+      }
+
       if (generatingAnswerMessage != null) {
         return false;
       }
@@ -370,7 +373,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       return false;
     })();
 
-
     const accept = () => {
       if (codeMirrorEditor?.view == null) {
         return;
@@ -384,17 +386,41 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       mutateIsEnableUnifiedMergeView(false);
     };
 
+    if (!isActionButtonShown) {
+      return <></>;
+    }
+
+    return (
+      <div className="d-flex mt-2 justify-content-start">
+        <button
+          type="button"
+          className="btn btn-outline-secondary me-2"
+          onClick={reject}
+        >
+          {t('sidebar_ai_assistant.discard')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-success"
+          onClick={accept}
+        >
+          {t('sidebar_ai_assistant.accept')}
+        </button>
+      </div>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView, t]);
+
+  const generatingEditorTextLabel = useMemo(() => {
     return (
-      <MessageCard
-        role={role}
-        showActionButtons={isActionButtonShown}
-        onAccept={accept}
-        onDiscard={reject}
-      >
-        {children}
-      </MessageCard>
+      <>
+        {isGeneratingEditorText && (
+          <span className="text-thinking">
+            {t('sidebar_ai_assistant.text_generation_by_editor_assistant_label')}
+          </span>
+        )}
+      </>
     );
-  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, t]);
 
   return {
     createThread,
@@ -403,10 +429,12 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     form,
     resetForm,
     isTextSelected,
+    isGeneratingEditorText,
 
     // Views
     generateInitialView,
-    generateMessageCard,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon,
     headerText,
     placeHolder,

+ 2 - 18
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -17,7 +17,6 @@ import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/mes
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
-import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useSWRMUTxMessages } from '../stores/message';
 import { useSWRMUTxThreads } from '../stores/thread';
@@ -36,10 +35,6 @@ interface ProcessMessage {
   ): void;
 }
 
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string): JSX.Element;
-}
-
 export interface FormData {
   input: string
   summaryMode?: boolean
@@ -59,7 +54,6 @@ type UseKnowledgeAssistant = () => {
 
   // Views
   initialView: JSX.Element
-  generateMessageCard: GenerateMessageCard
   generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
   headerIcon: JSX.Element
   headerText: JSX.Element
@@ -153,16 +147,6 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     );
   }, [aiAssistantSidebarData?.aiAssistantData]);
 
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
-    return (
-      <MessageCard
-        role={role}
-      >
-        {children}
-      </MessageCard>
-    );
-  }, []);
-
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
   const toggleDropdown = useCallback(() => {
@@ -244,7 +228,7 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
 
     // Views
     initialView,
-    generateMessageCard,
+    // generateMessageCard,
     generateModeSwitchesDropdown,
     headerIcon,
     headerText,
@@ -328,5 +312,5 @@ export const useFetchAndSetMessageDataEffect = (
     };
 
     fetchAndSetLogs();
-  }, [threadId, mutateMessageData, setMessageLogs]); // Dependencies
+  }, [threadId, mutateMessageData, setMessageLogs, aiAssistantSidebarData?.isEditorAssistant]); // Dependencies
 };

+ 1 - 1
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -45,7 +45,7 @@ export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
 
-      const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, user);
+      const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, user);
       if (!isAiAssistantUsable) {
         return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
       }

+ 57 - 23
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -69,29 +69,63 @@ const withMarkdownCaution = `# IMPORTANT:
 `;
 
 function instruction(withMarkdown: boolean): string {
-  return `# RESPONSE FORMAT:
-You must respond with a JSON object in the following format example:
-{
-  "contents": [
-    { "message": "Your brief message about the upcoming change or proposal.\n\n" },
-    { "replace": "New text 1" },
-    { "message": "Additional explanation if needed" },
-    { "replace": "New text 2" },
-    ...more items if needed
-    { "message": "Your friendly message explaining what changes were made or suggested." }
-  ]
-}
-
-The array should contain:
-- [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
-- Objects with a "message" key for explanatory text to the user if needed.
-- Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
-- [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
-
-${withMarkdown ? withMarkdownCaution : ''}
-
-# Multilingual Support:
-Always provide messages in the same language as the user's request.`;
+  return `
+  # USER INTENT DETECTION:
+  First, analyze the user's message to determine their intent:
+  - **Consultation Type**: Questions, discussions, explanations, or advice seeking WITHOUT explicit request to edit/modify/generate text
+  - **Edit Type**: Clear requests to edit, modify, fix, generate, create, or write content
+
+  ## EXAMPLES OF USER INTENT:
+  ### Consultation Type Examples:
+  - "What do you think about this code?"
+  - "Please give me advice on this text structure"
+  - "Why is this error occurring?"
+  - "Is there a better approach?"
+  - "Can you explain how this works?"
+  - "What are the pros and cons of this method?"
+  - "How should I organize this document?"
+
+  ### Edit Type Examples:
+  - "Please fix the following"
+  - "Add a function that..."
+  - "Rewrite this section to..."
+  - "Correct the errors in this code"
+  - "Generate a new paragraph about..."
+  - "Modify this to include..."
+  - "Create a template for..."
+
+  # RESPONSE FORMAT:
+  ## For Consultation Type (discussion/advice only):
+  Respond with a JSON object containing ONLY message objects:
+  {
+    "contents": [
+      { "message": "Your thoughtful response to the user's question or consultation.\n\nYou can use multiple paragraphs as needed." }
+    ]
+  }
+
+  ## For Edit Type (explicit editing request):
+  Respond with a JSON object in the following format:
+  {
+    "contents": [
+      { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+      { "replace": "New text 1" },
+      { "message": "Additional explanation if needed" },
+      { "replace": "New text 2" },
+      ...more items if needed
+      { "message": "Your friendly message explaining what changes were made or suggested." }
+    ]
+  }
+
+  The array should contain:
+  - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+  - Objects with a "message" key for explanatory text to the user if needed.
+  - Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
+  - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+  ${withMarkdown ? withMarkdownCaution : ''}
+
+  # Multilingual Support:
+  Always provide messages in the same language as the user's request.`;
 }
 /* eslint-disable max-len */
 

+ 1 - 1
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -43,7 +43,7 @@ export const getThreadsFactory: GetThreadsFactory = (crowi) => {
       try {
         const { aiAssistantId } = req.params;
 
-        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
         if (!isAiAssistantUsable) {
           return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
         }

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -47,7 +47,7 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     }
 
     const res = await pdfCtrlSyncJobStatus({
-      appId: appId?.toString(),
+      appId,
       jobId: pageBulkExportJob._id.toString(),
       expirationDate: bulkExportJobExpirationDate.toISOString(),
       status: pdfConvertStatus,

+ 6 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -47,7 +47,7 @@ import {
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
   useIsPdfBulkExportEnabled,
-  useIsAiEnabled, useLimitLearnablePageCountPerAssistant,
+  useIsAiEnabled, useLimitLearnablePageCountPerAssistant, useIsUsersHomepageDeletionEnabled,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -200,6 +200,7 @@ type Props = CommonProps & {
 
   aiEnabled: boolean,
   limitLearnablePageCountPerAssistant: number,
+  isUsersHomepageDeletionEnabled: boolean,
 };
 
 const Page: NextPageWithLayout<Props> = (props: Props) => {
@@ -258,6 +259,9 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsAiEnabled(props.aiEnabled);
   useLimitLearnablePageCountPerAssistant(props.limitLearnablePageCountPerAssistant);
 
+  useIsUsersHomepageDeletionEnabled(props.isUsersHomepageDeletionEnabled);
+
+
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
@@ -576,7 +580,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
   props.aiEnabled = configManager.getConfig('app:aiEnabled');
   props.limitLearnablePageCountPerAssistant = configManager.getConfig('openai:limitLearnablePageCountPerAssistant');
-
+  props.isUsersHomepageDeletionEnabled = configManager.getConfig('security:user-homepage-deletion:isEnabled');
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('customize:isSearchScopeChildrenAsDefault');

+ 10 - 0
apps/app/src/server/models/openapi/object-id.ts

@@ -0,0 +1,10 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      ObjectId:
+ *        type: string
+ *        description: Object ID
+ *        example: 5ae5fccfc5577b0004dbd8ab
+ */

+ 11 - 9
apps/app/src/server/models/openapi/page.ts

@@ -3,14 +3,20 @@
  *
  *  components:
  *    schemas:
+ *      PagePath:
+ *        description: Page path
+ *        type: string
+ *        example: /path/to/page
+ *      PageGrant:
+ *        description: Grant for page
+ *        type: number
+ *        example: 1
  *      Page:
  *        description: Page
  *        type: object
  *        properties:
  *          _id:
- *            type: string
- *            description: page ID
- *            example: 5e07345972560e001761fa63
+ *            $ref: '#/components/schemas/ObjectId'
  *          __v:
  *            type: number
  *            description: DB record version
@@ -30,9 +36,7 @@
  *            description: extend data
  *            example: {}
  *          grant:
- *            type: number
- *            description: grant
- *            example: 1
+ *            $ref: '#/components/schemas/PageGrant'
  *          grantedUsers:
  *            type: array
  *            description: granted users
@@ -50,9 +54,7 @@
  *              description: user ID
  *            example: []
  *          path:
- *            type: string
- *            description: page path
- *            example: /
+ *            $ref: '#/components/schemas/PagePath'
  *          revision:
  *            type: string
  *            description: page revision

+ 12 - 8
apps/app/src/server/models/openapi/paginate-result.js → apps/app/src/server/models/openapi/paginate-result.ts

@@ -4,6 +4,14 @@
  *
  *  components:
  *    schemas:
+ *      Offset:
+ *        description: Offset for pagination
+ *        type: integer
+ *        example: 0
+ *      Limit:
+ *        description: Limit for pagination
+ *        type: integer
+ *        example: 10
  *      PaginateResult:
  *        description: PaginateResult
  *        type: object
@@ -17,8 +25,7 @@
  *            type: number
  *            description: Total number of documents in collection that match a query
  *          limit:
- *            type: number
- *            description: Limit that was used
+ *            $ref: '#/components/schemas/Limit'
  *          hasPrevPage:
  *            type: number
  *            description: Availability of prev page.
@@ -32,8 +39,8 @@
  *            type: number
  *            description: Total number of pages.
  *          offset:
- *            type: number
  *            description: Only if specified or default page/offset values were used
+ *            $ref: '#/components/schemas/Offset'
  *          prefPage:
  *            type: number
  *            description: Previous page number if available or NULL
@@ -66,13 +73,10 @@
  *                description: Total number of documents in collection that match a query
  *                example: 35
  *              limit:
- *                type: integer
- *                description: Limit that was used
- *                example: 10
+ *                $ref: '#/components/schemas/Limit'
  *              offset:
- *                type: integer
  *                description: Only if specified or default page/offset values were used
- *                example: 20
+ *                $ref: '#/components/schemas/Offset'
  *          data:
  *            type: object
  *            description: Object of pagination meta data.

+ 12 - 10
apps/app/src/server/models/openapi/revision.ts

@@ -3,27 +3,29 @@
  *
  *  components:
  *    schemas:
+ *      RevisionBody:
+ *        description: Revision content body
+ *        type: string
+ *        example: |
+ *          # Header
+ *
+ *          - foo
+ *          - bar
+ *
  *      Revision:
  *        description: Revision
  *        type: object
  *        properties:
  *          _id:
- *            type: string
- *            description: revision ID
- *            example: 5e0734e472560e001761fa68
+ *            $ref: '#/components/schemas/ObjectId'
  *          __v:
  *            type: number
  *            description: DB record version
  *            example: 0
  *          author:
- *            $ref: '#/components/schemas/User/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *          body:
- *            type: string
- *            description: content body
- *            example: |
- *              # test
- *
- *              test
+ *            $ref: '#/components/schemas/RevisionBody'
  *          format:
  *            type: string
  *            description: format

+ 32 - 0
apps/app/src/server/models/openapi/tag.ts

@@ -0,0 +1,32 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Tags:
+ *        description: Tags
+ *        type: array
+ *        items:
+ *          $ref: '#/components/schemas/TagName'
+ *        example: ['daily', 'report', 'tips']
+ *
+ *      TagName:
+ *        description: Tag name
+ *        type: string
+ *        example: daily
+ *
+ *      Tag:
+ *        description: Tag
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: tag ID
+ *            example: 5e2d6aede35da4004ef7e0b7
+ *          name:
+ *            $ref: '#/components/schemas/TagName'
+ *          count:
+ *            type: number
+ *            description: Count of tagged pages
+ *            example: 3
+ */

+ 4 - 3
apps/app/src/server/models/openapi/v1-response.js

@@ -3,14 +3,15 @@
  *
  *  components:
  *    schemas:
+ *      V1ResponseOK:
+ *        description: API is succeeded
+ *        type: boolean
  *      V1Response:
  *        description: Response v1
  *        type: object
  *        properties:
  *          ok:
- *            type: boolean
- *            description: API is succeeded
- *            example: true
+ *            $ref: '#/components/schemas/V1ResponseOK'
  *    responses:
  *      403:
  *        description: 'Forbidden'

+ 0 - 1
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -68,7 +68,6 @@ module.exports = (crowi) => {
    *    /admin-home/:
    *      get:
    *        tags: [AdminHome]
-   *        operationId: getAdminHome
    *        summary: /admin-home
    *        security:
    *          - cookieAuth: []

+ 79 - 41
apps/app/src/server/routes/apiv3/app-settings.js

@@ -1,4 +1,6 @@
-import { ConfigSource } from '@growi/core/dist/interfaces';
+import {
+  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined,
+} from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 
@@ -367,6 +369,7 @@ module.exports = (crowi) => {
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
       body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+      body('s3Bucket').trim(),
       body('s3Region')
         .trim()
         .if(value => value !== '')
@@ -387,7 +390,6 @@ module.exports = (crowi) => {
           }
           return true;
         }),
-      body('s3Bucket').trim(),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
@@ -418,7 +420,6 @@ module.exports = (crowi) => {
    *    /app-settings:
    *      get:
    *        tags: [AppSettings]
-   *        operationId: getAppSettings
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -515,7 +516,6 @@ module.exports = (crowi) => {
    *    /app-settings/app-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettings
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/app-setting
@@ -576,7 +576,6 @@ module.exports = (crowi) => {
    *    /app-settings/site-url-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingSiteUrlSetting
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/site-url-setting
@@ -727,7 +726,6 @@ module.exports = (crowi) => {
    *    /app-settings/smtp-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingSmtpSetting
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/smtp-setting
@@ -779,7 +777,6 @@ module.exports = (crowi) => {
    *    /app-settings/smtp-test:
    *      post:
    *        tags: [AppSettings]
-   *        operationId: postSmtpTest
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/smtp-setting
@@ -816,7 +813,6 @@ module.exports = (crowi) => {
    *    /app-settings/ses-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingSesSetting
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/ses-setting
@@ -868,7 +864,6 @@ module.exports = (crowi) => {
    *    /app-settings/file-upload-settings:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingFileUploadSetting
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/file-upload-setting
@@ -895,42 +890,88 @@ module.exports = (crowi) => {
   router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     const { fileUploadType } = req.body;
 
-    const requestParams = {
-      'app:fileUploadType': fileUploadType,
-    };
-
-    if (fileUploadType === 'gcs') {
-      requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
-      requestParams['gcs:bucket'] = req.body.gcsBucket;
-      requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
-      requestParams['gcs:referenceFileWithRelayMode'] = req.body.gcsReferenceFileWithRelayMode;
+    if (fileUploadType === 'aws') {
+      try {
+        try {
+          toNonBlankString(req.body.s3Bucket);
+        }
+        catch (err) {
+          throw new Error('S3 Bucket name is required');
+        }
+        try {
+          toNonBlankString(req.body.s3Region);
+        }
+        catch (err) {
+          throw new Error('S3 Region is required');
+        }
+        await configManager.updateConfigs({
+          'app:fileUploadType': fileUploadType,
+          'aws:s3Region': toNonBlankString(req.body.s3Region),
+          'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+          'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
+        },
+        { skipPubsub: true });
+        await configManager.updateConfigs({
+          'app:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
+          'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+          'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
+        },
+        {
+          skipPubsub: true,
+          removeIfUndefined: true,
+        });
+      }
+      catch (err) {
+        const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      }
     }
 
-    if (fileUploadType === 'aws') {
-      requestParams['aws:s3Region'] = req.body.s3Region;
-      requestParams['aws:s3CustomEndpoint'] = req.body.s3CustomEndpoint;
-      requestParams['aws:s3Bucket'] = req.body.s3Bucket;
-      requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
-      requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
+    if (fileUploadType === 'gcs') {
+      try {
+        await configManager.updateConfigs({
+          'app:fileUploadType': fileUploadType,
+          'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
+        },
+        { skipPubsub: true });
+        await configManager.updateConfigs({
+          'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
+          'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+          'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
+        },
+        { skipPubsub: true, removeIfUndefined: true });
+      }
+      catch (err) {
+        const msg = `Error occurred in updating GCS settings: ${err.message}`;
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      }
     }
 
     if (fileUploadType === 'azure') {
-      requestParams['azure:tenantId'] = req.body.azureTenantId;
-      requestParams['azure:clientId'] = req.body.azureClientId;
-      requestParams['azure:clientSecret'] = req.body.azureClientSecret;
-      requestParams['azure:storageAccountName'] = req.body.azureStorageAccountName;
-      requestParams['azure:storageContainerName'] = req.body.azureStorageContainerName;
-      requestParams['azure:referenceFileWithRelayMode'] = req.body.azureReferenceFileWithRelayMode;
+      try {
+        await configManager.updateConfigs({
+          'app:fileUploadType': fileUploadType,
+          'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
+        },
+        { skipPubsub: true });
+        await configManager.updateConfigs({
+          'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
+          'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
+          'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
+          'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
+          'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
+        }, { skipPubsub: true, removeIfUndefined: true });
+      }
+      catch (err) {
+        const msg = `Error occurred in updating Azure settings: ${err.message}`;
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      }
     }
 
     try {
-      await configManager.updateConfigs(requestParams, { skipPubsub: true });
-
-      const s3SecretAccessKey = req.body.s3SecretAccessKey;
-      if (fileUploadType === 'aws' && s3SecretAccessKey != null && s3SecretAccessKey.trim() !== '') {
-        await configManager.updateConfigs({ 'aws:s3SecretAccessKey': s3SecretAccessKey }, { skipPubsub: true });
-      }
-
       await crowi.setUpFileUpload(true);
       crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
@@ -966,7 +1007,7 @@ module.exports = (crowi) => {
       return res.apiv3({ responseParams });
     }
     catch (err) {
-      const msg = 'Error occurred in updating fileUploadType';
+      const msg = 'Error occurred in retrieving file upload configurations';
       logger.error('Error', err);
       return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
     }
@@ -979,7 +1020,6 @@ module.exports = (crowi) => {
    *    /app-settings/questionnaire-settings:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingQuestionnaireSettings
    *        security:
    *          - cookieAuth: []
    *        summary: /app-settings/questionnaire-settings
@@ -1064,7 +1104,6 @@ module.exports = (crowi) => {
    *    /app-settings/v5-schema-migration:
    *      post:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingV5SchemaMigration
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -1110,7 +1149,6 @@ module.exports = (crowi) => {
    *    /app-settings/maintenance-mode:
    *      post:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingMaintenanceMode
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []

+ 0 - 2
apps/app/src/server/routes/apiv3/attachment.js

@@ -245,7 +245,6 @@ module.exports = (crowi) => {
    *    /attachment/limit:
    *      get:
    *        tags: [Attachment]
-   *        operationId: getAttachmentLimit
    *        summary: /attachment/limit
    *        description: Get available capacity of uploaded file with GridFS
    *        parameters:
@@ -290,7 +289,6 @@ module.exports = (crowi) => {
    *    /attachment:
    *      post:
    *        tags: [Attachment]
-   *        operationId: addAttachment
    *        summary: /attachment
    *        description: Add attachment to the page
    *        requestBody:

+ 0 - 6
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -128,7 +128,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder:
    *      post:
    *        tags: [BookmarkFolders]
-   *        operationId: createBookmarkFolder
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -184,7 +183,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder/list/{userId}:
    *      get:
    *        tags: [BookmarkFolders]
-   *        operationId: listBookmarkFolders
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -274,7 +272,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder/{id}:
    *      delete:
    *        tags: [BookmarkFolders]
-   *        operationId: deleteBookmarkFolder
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -318,7 +315,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder:
    *      put:
    *        tags: [BookmarkFolders]
-   *        operationId: updateBookmarkFolder
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -376,7 +372,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder/add-bookmark-to-folder:
    *      post:
    *        tags: [BookmarkFolders]
-   *        operationId: addBookmarkToFolder
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
@@ -427,7 +422,6 @@ module.exports = (crowi) => {
    *    /bookmark-folder/update-bookmark:
    *      put:
    *        tags: [BookmarkFolders]
-   *        operationId: updateBookmarkInFolder
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []

+ 1 - 4
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -42,7 +42,7 @@ const router = express.Router();
  *          page:
  *            $ref: '#/components/schemas/Page'
  *          user:
- *            $ref: '#/components/schemas/User/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *      Bookmarks:
  *        description: User Root Bookmarks
  *        type: object
@@ -110,7 +110,6 @@ module.exports = (crowi) => {
    *        tags: [Bookmarks]
    *        summary: /bookmarks/info
    *        description: Get bookmarked info
-   *        operationId: getBookmarkedInfo
    *        parameters:
    *          - name: pageId
    *            in: query
@@ -172,7 +171,6 @@ module.exports = (crowi) => {
    *        tags: [Bookmarks]
    *        summary: /bookmarks/{userId}
    *        description: Get my bookmarked status
-   *        operationId: getMyBookmarkedStatus
    *        parameters:
    *          - name: userId
    *            in: path
@@ -232,7 +230,6 @@ module.exports = (crowi) => {
    *        tags: [Bookmarks]
    *        summary: /bookmarks
    *        description: Update bookmarked status
-   *        operationId: updateBookmarkedStatus
    *        requestBody:
    *          content:
    *            application/json:

+ 1 - 17
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -259,7 +259,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getCustomizeSetting
    *        summary: /customize-setting
    *        description: Get customize parameters
    *        responses:
@@ -306,7 +305,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getLayoutCustomizeSetting
    *        summary: /customize-setting/layout
    *        description: Get layout
    *        responses:
@@ -335,7 +333,6 @@ module.exports = (crowi) => {
    *    /customize-setting/layout:
    *      put:
    *        tags: [CustomizeSetting]
-   *        operationId: updateLayoutCustomizeSetting
    *        summary: /customize-setting/layout
    *        description: Update layout
    *        requestBody:
@@ -388,7 +385,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getThemeCustomizeSetting
    *        summary: /customize-setting/theme
    *        description: Get theme
    *        responses:
@@ -437,7 +433,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateThemeCustomizeSetting
    *        summary: /customize-setting/theme
    *        description: Update theme
    *        requestBody:
@@ -487,7 +482,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getCustomeSettingSidebar
    *        summary: /customize-setting/sidebar
    *        description: Get sidebar
    *        responses:
@@ -520,7 +514,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeSettingSidebar
    *        summary: /customize-setting/sidebar
    *        description: Update sidebar
    *        requestBody:
@@ -572,7 +565,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *         - cookieAuth: []
-   *        operationId: updateFunctionCustomizeSetting
    *        summary: /customize-setting/function
    *        description: Update function
    *        requestBody:
@@ -640,7 +632,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *         - cookieAuth: []
-   *        operationId: updatePresentationCustomizeSetting
    *        summary: /customize-setting/presentation
    *        description: Update presentation
    *        requestBody:
@@ -689,7 +680,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateHighlightCustomizeSetting
    *        summary: /customize-setting/highlight
    *        description: Update highlight
    *        requestBody:
@@ -740,7 +730,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeTitleCustomizeSetting
    *        summary: /customize-setting/customizeTitle
    *        description: Update title
    *        requestBody:
@@ -792,7 +781,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeNoscriptCustomizeSetting
    *        summary: /customize-setting/customize-noscript
    *        description: Update noscript
    *        requestBody:
@@ -840,7 +828,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeCssCustomizeSetting
    *        summary: /customize-setting/customize-css
    *        description: Update customize css
    *        requestBody:
@@ -891,7 +878,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeScriptCustomizeSetting
    *        summary: /customize-setting/customize-script
    *        description: Update customize script
    *        requestBody:
@@ -939,7 +925,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateCustomizeLogoCustomizeSetting
    *        summary: /customize-setting/customize-logo
    *        description: Update customize logo
    *        requestBody:
@@ -990,7 +975,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: uploadBrandLogoCustomizeSetting
    *        summary: /customize-setting/upload-brand-logo
    *        description: Upload brand logo
    *        requestBody:
@@ -1001,6 +985,7 @@ module.exports = (crowi) => {
    *               type: object
    *               properties:
    *                 file:
+   *                   type: string
    *                   format: binary
    *        responses:
    *          200:
@@ -1066,7 +1051,6 @@ module.exports = (crowi) => {
    *        tags: [CustomizeSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: deleteBrandLogoCustomizeSetting
    *        summary: /customize-setting/delete-brand-logo
    *        description: Delete brand logo
    *        responses:

+ 0 - 3
apps/app/src/server/routes/apiv3/export.js

@@ -156,7 +156,6 @@ module.exports = (crowi) => {
    *  /export/status:
    *    get:
    *      tags: [Export]
-   *      operationId: getExportStatus
    *      summary: /export/status
    *      description: get properties of stored zip files for export
    *      responses:
@@ -188,7 +187,6 @@ module.exports = (crowi) => {
    *  /export:
    *    post:
    *      tags: [Export]
-   *      operationId: createExport
    *      summary: /export
    *      description: generate zipped jsons for collections
    *      requestBody:
@@ -241,7 +239,6 @@ module.exports = (crowi) => {
    *  /export/{fileName}:
    *    delete:
    *      tags: [Export]
-   *      operationId: deleteExport
    *      summary: /export/{fileName}
    *      description: delete the file
    *      parameters:

+ 2 - 0
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -220,6 +220,7 @@ module.exports = (crowi: Crowi): Router => {
    *              type: object
    *              properties:
    *                file:
+   *                  type: string
    *                  format: binary
    *                  description: The zip file of the data to be transferred
    *                collections:
@@ -349,6 +350,7 @@ module.exports = (crowi: Crowi): Router => {
    *              type: object
    *              properties:
    *                file:
+   *                  type: string
    *                  format: binary
    *                  description: The zip file of the data to be transferred
    *                attachmentMetadata:

+ 0 - 1
apps/app/src/server/routes/apiv3/healthcheck.ts

@@ -111,7 +111,6 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [Healthcheck]
    *      security: []
-   *      operationId: getHealthcheck
    *      summary: /healthcheck
    *      description: Check whether the server is healthy or not
    *      parameters:

+ 1 - 5
apps/app/src/server/routes/apiv3/import.js

@@ -171,7 +171,6 @@ export default function route(crowi) {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: getImportSettingsParams
    *      summary: /import
    *      description: Get import settings params
    *      responses:
@@ -224,7 +223,6 @@ export default function route(crowi) {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: getImportStatus
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
    *      responses:
@@ -256,7 +254,6 @@ export default function route(crowi) {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: executeImport
    *      summary: /import
    *      description: import a collection from a zipped json
    *      requestBody:
@@ -389,7 +386,6 @@ export default function route(crowi) {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: uploadImport
    *      summary: /import/upload
    *      description: upload a zip file
    *      requestBody:
@@ -399,6 +395,7 @@ export default function route(crowi) {
    *              type: object
    *              properties:
    *                file:
+   *                  type: string
    *                  format: binary
    *      responses:
    *        200:
@@ -446,7 +443,6 @@ export default function route(crowi) {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: deleteImportAll
    *      summary: /import/all
    *      description: Delete all zip files
    *      responses:

+ 0 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -105,7 +105,6 @@ module.exports = (crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: getInAppNotificationList
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
    *      parameters:
@@ -196,7 +195,6 @@ module.exports = (crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: getInAppNotificationStatus
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
    *      responses:
@@ -233,7 +231,6 @@ module.exports = (crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: openInAppNotification
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
    *      requestBody:
@@ -280,7 +277,6 @@ module.exports = (crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
-   *      operationId: openAllInAppNotification
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications
    *      responses:

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

@@ -43,7 +43,6 @@ module.exports = (crowi: Crowi): Router => {
    *    post:
    *      tags: [Install]
    *      security: []
-   *      operationId: Install
    *      summary: /installer
    *      description: Install GROWI
    *      requestBody:

+ 0 - 1
apps/app/src/server/routes/apiv3/invited.ts

@@ -26,7 +26,6 @@ module.exports = (crowi: Crowi): Router => {
    *      tags: [Users]
    *      security:
    *        - cookieAuth: []
-   *      operationId: activateInvitedUser
    *      summary: /invited
    *      description: Activate invited user
    *      requestBody:

+ 0 - 4
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -136,7 +136,6 @@ module.exports = (crowi) => {
    *        tags: [MarkDownSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getMarkdownSetting
    *        summary: Get markdown parameters
    *        responses:
    *          200:
@@ -173,7 +172,6 @@ module.exports = (crowi) => {
    *        tags: [MarkDownSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateLineBreakMarkdownSetting
    *        summary: Update lineBreak setting
    *        requestBody:
    *          required: true
@@ -228,7 +226,6 @@ module.exports = (crowi) => {
    *        tags: [MarkDownSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateIndentMarkdownSetting
    *        summary: Update indent setting
    *        requestBody:
    *          required: true
@@ -284,7 +281,6 @@ module.exports = (crowi) => {
    *        tags: [MarkDownSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateXssMarkdownSetting
    *        summary: Update XSS setting
    *        description: Update xss
    *        requestBody:

+ 0 - 1
apps/app/src/server/routes/apiv3/mongo.js

@@ -18,7 +18,6 @@ module.exports = (crowi) => {
    *  /mongo/collections:
    *    get:
    *      tags: [MongoDB]
-   *      operationId: getMongoCollections
    *      summary: /mongo/collections
    *      description: get mongodb collections names
    *      responses:

+ 12 - 23
apps/app/src/server/routes/apiv3/page/index.ts

@@ -190,7 +190,6 @@ module.exports = (crowi) => {
    *    /page:
    *      get:
    *        tags: [Page]
-   *        operationId: getPage
    *        summary: Get page
    *        description: get page by pagePath or pageId
    *        parameters:
@@ -198,12 +197,12 @@ module.exports = (crowi) => {
    *            in: query
    *            description: page id
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
+   *              $ref: '#/components/schemas/ObjectId'
    *          - name: path
    *            in: query
    *            description: page path
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
+   *              $ref: '#/components/schemas/PagePath'
    *        responses:
    *          200:
    *            description: Page data
@@ -304,7 +303,6 @@ module.exports = (crowi) => {
    *      post:
    *        tags: [Page]
    *        summary: Create page
-   *        operationId: createPage
    *        description: Create page
    *        requestBody:
    *          content:
@@ -315,20 +313,17 @@ module.exports = (crowi) => {
    *                    type: string
    *                    description: Text of page
    *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                    $ref: '#/components/schemas/PagePath'
    *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                  grantUserGroupId:
+   *                    $ref: '#/components/schemas/PageGrant'
+   *                  grantUserGroupIds:
    *                    type: string
    *                    description: UserGroup ID
    *                    example: 5ae5fccfc5577b0004dbd8ab
    *                  pageTags:
    *                    type: array
    *                    items:
-   *                      $ref: '#/components/schemas/Tag'
-   *                  shouldGeneratePath:
-   *                    type: boolean
-   *                    description: Determine whether a new path should be generated
+   *                      type: string
    *                required:
    *                  - body
    *                  - path
@@ -361,7 +356,6 @@ module.exports = (crowi) => {
    *    /page:
    *      put:
    *        tags: [Page]
-   *        operationId: updatePage
    *        description: Update page
    *        requestBody:
    *          content:
@@ -369,13 +363,13 @@ module.exports = (crowi) => {
    *              schema:
    *                properties:
    *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                    $ref: '#/components/schemas/RevisionBody'
    *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  revisionId:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                    $ref: '#/components/schemas/PageGrant'
    *                  userRelatedGrantUserGroupIds:
    *                    type: array
    *                    items:
@@ -429,7 +423,6 @@ module.exports = (crowi) => {
    *        tags: [Page]
    *        summary: Get page likes
    *        description: Update liked status
-   *        operationId: updateLikedStatus
    *        requestBody:
    *          content:
    *            application/json:
@@ -497,7 +490,6 @@ module.exports = (crowi) => {
    *        tags: [Page]
    *        summary: Get page info
    *        description: Retrieve current page info
-   *        operationId: getPageInfo
    *        requestBody:
    *          content:
    *            application/json:
@@ -541,13 +533,12 @@ module.exports = (crowi) => {
    *        tags: [Page]
    *        summary: Get page grant data
    *        description: Retrieve current page's grant data
-   *        operationId: getPageGrantData
    *        parameters:
    *          - name: pageId
    *            in: query
    *            description: page id
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
+   *              $ref: '#/components/schemas/ObjectId'
    *        responses:
    *          200:
    *            description: Successfully retrieved current grant data.
@@ -954,7 +945,6 @@ module.exports = (crowi) => {
    *          - cookieAuth: []
    *        summary: Get already exist paths
    *        description: Get already exist paths
-   *        operationId: getAlreadyExistPaths
    *        parameters:
    *          - name: fromPath
    *            in: query
@@ -1015,14 +1005,13 @@ module.exports = (crowi) => {
    *        tags: [Page]
    *        summary: Update subscription status
    *        description: Update subscription status
-   *        operationId: updateSubscriptionStatus
    *        requestBody:
    *          content:
    *            application/json:
    *              schema:
    *                properties:
    *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *        responses:
    *          200:
    *            description: Succeeded to update subscription status.

+ 5 - 47
apps/app/src/server/routes/apiv3/pages/index.js

@@ -29,35 +29,6 @@ const router = express.Router();
 const LIMIT_FOR_LIST = 10;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      Tags:
- *        description: Tags
- *        type: array
- *        items:
- *          $ref: '#/components/schemas/Tag/properties/name'
- *        example: ['daily', 'report', 'tips']
- *
- *      Tag:
- *        description: Tag
- *        type: object
- *        properties:
- *          _id:
- *            type: string
- *            description: tag ID
- *            example: 5e2d6aede35da4004ef7e0b7
- *          name:
- *            type: string
- *            description: tag name
- *            example: daily
- *          count:
- *            type: number
- *            description: Count of tagged pages
- *            example: 3
- */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
   const loginRequired = require('../../../middlewares/login-required')(crowi, true);
@@ -86,7 +57,6 @@ module.exports = (crowi) => {
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
-      body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     resumeRenamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -228,7 +198,6 @@ module.exports = (crowi) => {
    *    /pages/rename:
    *      post:
    *        tags: [Pages]
-   *        operationId: renamePage
    *        description: Rename page
    *        requestBody:
    *          content:
@@ -236,9 +205,9 @@ module.exports = (crowi) => {
    *              schema:
    *                properties:
    *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                    $ref: '#/components/schemas/PagePath'
    *                  revisionId:
    *                    type: string
    *                    description: revision ID
@@ -256,9 +225,6 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether rename page with descendants
-   *                  isMoveMode:
-   *                    type: boolean
-   *                    description: whether rename page with moving
    *                required:
    *                  - pageId
    *                  - revisionId
@@ -285,7 +251,6 @@ module.exports = (crowi) => {
       isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: req.body.updateMetadata,
-      isMoveMode: req.body.isMoveMode,
     };
 
     const activityParameters = {
@@ -359,7 +324,6 @@ module.exports = (crowi) => {
     *    /pages/resume-rename:
     *      post:
     *        tags: [Pages]
-    *        operationId: resumeRenamePage
     *        description: Resume rename page operation
     *        requestBody:
     *          content:
@@ -367,7 +331,7 @@ module.exports = (crowi) => {
     *              schema:
     *                properties:
     *                  pageId:
-    *                    $ref: '#/components/schemas/Page/properties/_id'
+    *                    $ref: '#/components/schemas/ObjectId'
     *                required:
     *                  - pageId
     *        responses:
@@ -487,7 +451,6 @@ module.exports = (crowi) => {
     *    /pages/list:
     *      get:
     *        tags: [Pages]
-    *        operationId: getList
     *        description: Get list of pages
     *        parameters:
     *          - name: path
@@ -577,7 +540,6 @@ module.exports = (crowi) => {
    *    /pages/duplicate:
    *      post:
    *        tags: [Pages]
-   *        operationId: duplicatePage
    *        description: Duplicate page
    *        requestBody:
    *          content:
@@ -585,9 +547,9 @@ module.exports = (crowi) => {
    *              schema:
    *                properties:
    *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  pageNameInput:
-   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                    $ref: '#/components/schemas/PagePath'
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether duplicate page with descendants
@@ -683,7 +645,6 @@ module.exports = (crowi) => {
    *    /pages/subordinated-list:
    *      get:
    *        tags: [Pages]
-   *        operationId: subordinatedList
    *        description: Get subordinated pages
    *        parameters:
    *          - name: path
@@ -729,7 +690,6 @@ module.exports = (crowi) => {
     *    /pages/delete:
     *      post:
     *        tags: [Pages]
-    *        operationId: deletePages
     *        description: Delete pages
     *        requestBody:
     *          content:
@@ -828,7 +788,6 @@ module.exports = (crowi) => {
    *    /pages/convert-pages-by-path:
    *      post:
    *        tags: [Pages]
-   *        operationId: convertPagesByPath
    *        description: Convert pages by path
    *        requestBody:
    *          content:
@@ -876,7 +835,6 @@ module.exports = (crowi) => {
    *    /pages/legacy-pages-migration:
    *      post:
    *        tags: [Pages]
-   *        operationId: legacyPagesMigration
    *        description: Migrate legacy pages
    *        requestBody:
    *          content:

+ 0 - 14
apps/app/src/server/routes/apiv3/personal-setting.js

@@ -134,7 +134,6 @@ module.exports = (crowi) => {
    *    /personal-setting:
    *      get:
    *        tags: [GeneralSetting]
-   *        operationId: getPersonalSetting
    *        summary: /personal-setting
    *        description: Get personal parameters
    *        responses:
@@ -173,7 +172,6 @@ module.exports = (crowi) => {
    *    /personal-setting/is-password-set:
    *      get:
    *        tags: [GeneralSetting]
-   *        operationId: getIsPasswordSet
    *        summary: /personal-setting
    *        description: Get whether a password has been set
    *        responses:
@@ -212,7 +210,6 @@ module.exports = (crowi) => {
    *    /personal-setting:
    *      put:
    *        tags: [GeneralSetting]
-   *        operationId: updatePersonalSetting
    *        summary: /personal-setting
    *        description: Update personal setting
    *        requestBody:
@@ -269,7 +266,6 @@ module.exports = (crowi) => {
    *    /personal-setting/image-type:
    *      put:
    *        tags: [GeneralSetting]
-   *        operationId: putUserImageType
    *        summary: /personal-setting/image-type
    *        description: Update user image type
    *        requestBody:
@@ -315,7 +311,6 @@ module.exports = (crowi) => {
    *    /personal-setting/external-accounts:
    *      get:
    *        tags: [GeneralSetting]
-   *        operationId: getExternalAccounts
    *        summary: /personal-setting/external-accounts
    *        description: Get external accounts that linked current user
    *        responses:
@@ -349,7 +344,6 @@ module.exports = (crowi) => {
    *    /personal-setting/password:
    *      put:
    *        tags: [GeneralSetting]
-   *        operationId: putUserPassword
    *        summary: /personal-setting/password
    *        description: Update user password
    *        requestBody:
@@ -404,7 +398,6 @@ module.exports = (crowi) => {
    *        tags: [GeneralSetting]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putUserApiToken
    *        summary: /personal-setting/api-token
    *        description: Update user api token
    *        responses:
@@ -442,7 +435,6 @@ module.exports = (crowi) => {
    *    /personal-setting/associate-ldap:
    *      put:
    *        tags: [GeneralSetting]
-   *        operationId: associateLdapAccount
    *        summary: /personal-setting/associate-ldap
    *        description: associate Ldap account
    *        requestBody:
@@ -497,7 +489,6 @@ module.exports = (crowi) => {
    *    /personal-setting/disassociate-ldap:
    *      put:
    *        tags: [GeneralSetting]
-   *        operationId: disassociateLdapAccount
    *        summary: /personal-setting/disassociate-ldap
    *        description: disassociate Ldap account
    *        requestBody:
@@ -552,7 +543,6 @@ module.exports = (crowi) => {
    *    /personal-setting/editor-settings:
    *      put:
    *        tags: [EditorSetting]
-   *        operationId: putEditorSettings
    *        summary: /personal-setting/editor-settings
    *        description: Put editor preferences
    *        requestBody:
@@ -614,7 +604,6 @@ module.exports = (crowi) => {
    *    /personal-setting/editor-settings:
    *      get:
    *        tags: [EditorSetting]
-   *        operationId: getEditorSettings
    *        summary: /personal-setting/editor-settings
    *        description: Get editor preferences
    *        responses:
@@ -644,7 +633,6 @@ module.exports = (crowi) => {
    *    /personal-setting/in-app-notification-settings:
    *      put:
    *        tags: [InAppNotificationSettings]
-   *        operationId: putInAppNotificationSettings
    *        summary: /personal-setting/in-app-notification-settings
    *        description: Put InAppNotificationSettings
    *        requestBody:
@@ -700,7 +688,6 @@ module.exports = (crowi) => {
    *    /personal-setting/in-app-notification-settings:
    *      get:
    *        tags: [InAppNotificationSettings]
-   *        operationId: getInAppNotificationSettings
    *        summary: personal-setting/in-app-notification-settings
    *        description: Get InAppNotificationSettings
    *        responses:
@@ -731,7 +718,6 @@ module.exports = (crowi) => {
    *   /personal-setting/questionnaire-settings:
    *     put:
    *       tags: [QuestionnaireSetting]
-   *       operationId: putQuestionnaireSetting
    *       summary: /personal-setting/questionnaire-settings
    *       description: Update the questionnaire settings for the current user
    *       requestBody:

+ 0 - 13
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -162,7 +162,6 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/:
    *      get:
    *        tags: [SlackIntegrationSettings]
-   *        operationId: getSlackBotSettingParams
    *        summary: /slack-integration-settings
    *        description: Get current settings and connection statuses.
    *        responses:
@@ -321,7 +320,6 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/bot-type/:
    *      put:
    *        tags: [SlackIntegrationSettings]
-   *        operationId: putBotType
    *        summary: /slack-integration/bot-type
    *        description: Put botType setting.
    *        requestBody:
@@ -360,7 +358,6 @@ module.exports = (crowi) => {
    *    /slack-integration/bot-type/:
    *      delete:
    *        tags: [SlackIntegrationSettings]
-   *        operationId: deleteBotType
    *        summary: /slack-integration/bot-type
    *        description: Delete botType setting.
    *        requestBody:
@@ -393,7 +390,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putWithoutProxySettings
    *        summary: /slack-integration-settings/without-proxy/update-settings
    *        description: Update customBotWithoutProxy setting.
    *        requestBody:
@@ -446,7 +442,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putWithoutProxyPermissions
    *        summary: /slack-integration-settings/without-proxy/update-permissions
    *        description: Update customBotWithoutProxy permissions.
    *        requestBody:
@@ -502,7 +497,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putSlackAppIntegrations
    *        summary: /slack-integration-settings/slack-app-integrations
    *        description: Generate SlackAppIntegrations
    *        responses:
@@ -571,7 +565,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: deleteAccessTokens
    *        summary: /slack-integration-settings/slack-app-integrations/:id
    *        description: Delete accessTokens
    *        parameters:
@@ -622,7 +615,6 @@ module.exports = (crowi) => {
    *       tags: [SlackIntegrationSettings (with proxy)]
    *       security:
    *         - cookieAuth: []
-   *       operationId: putProxyUri
    *       summary: /slack-integration-settings/proxy-uri
    *       description: Update proxy uri
    *       requestBody:
@@ -670,7 +662,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: makePrimary
    *        summary: /slack-integration-settings/slack-app-integrations/:id/makeprimary
    *        description: Make SlackAppTokens primary
    *        parameters:
@@ -725,7 +716,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putRegenerateTokens
    *        summary: /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens
    *        description: Regenerate SlackAppTokens
    *        parameters:
@@ -770,7 +760,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: putSupportedCommands
    *        summary: /slack-integration-settings/slack-app-integrations/:id/permissions
    *        description: update supported commands
    *        parameters:
@@ -853,7 +842,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: postRelationTest
    *        summary: /slack-integration-settings/slack-app-integrations/:id/relation-test
    *        description: Delete botType setting.
    *        parameters:
@@ -940,7 +928,6 @@ module.exports = (crowi) => {
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        security:
    *          - cookieAuth: []
-   *        operationId: postTest
    *        summary: /slack-integration-settings/without-proxy/test
    *        description: Test the connection with slack work space.
    *        requestBody:

+ 0 - 1
apps/app/src/server/routes/apiv3/statistics.js

@@ -121,7 +121,6 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [Statistics]
    *      security: []
-   *      operationId: getStatisticsUser
    *      summary: /statistics/user
    *      description: Get statistics for user
    *      responses:

+ 0 - 1
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -78,7 +78,6 @@ async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, tem
  *     summary: /complete-registration
  *     tags: [Users]
  *     security: []
- *     operationId: completeRegistration
  *     requestBody:
  *       required: true
  *       content:

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

@@ -31,7 +31,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroupRelations]
    *        security:
    *          - cookieAuth: []
-   *        operationId: listUserGroupRelations
    *        summary: /user-group-relations
    *        description: Gets the user group relations
    *        responses:

+ 0 - 15
apps/app/src/server/routes/apiv3/user-group.js

@@ -91,7 +91,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getUserGroup
    *        summary: /user-groups
    *        description: Get usergroups
    *        parameters:
@@ -167,7 +166,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getAncestorUserGroups
    *        summary: /user-groups/ancestors
    *        description: Get ancestor user groups.
    *        parameters:
@@ -213,7 +211,6 @@ module.exports = (crowi) => {
    *          tags: [UserGroups]
    *          security:
    *            - cookieAuth: []
-   *          operationId: getUserGroupChildren
    *          summary: /user-groups/children
    *          description: Get child user groups
    *          parameters:
@@ -276,7 +273,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: createUserGroup
    *        summary: /user-groups
    *        description: Adds userGroup
    *        requestBody:
@@ -334,7 +330,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getSelectableParentGroups
    *        summary: /selectable-parent-groups
    *        description: Get selectable parent UserGroups
    *        parameters:
@@ -385,7 +380,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getSelectableChildGroups
    *        summary: /selectable-child-groups
    *        description: Get selectable child UserGroups
    *        parameters:
@@ -439,7 +433,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getUserGroupFromGroupId
    *        summary: /user-groups/{id}
    *        description: Get UserGroup from Group ID
    *        parameters:
@@ -483,7 +476,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: deleteUserGroup
    *        summary: /user-groups/{id}
    *        description: Deletes userGroup
    *        parameters:
@@ -554,7 +546,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: updateUserGroups
    *        summary: /user-groups/{id}
    *        description: Update userGroup
    *        parameters:
@@ -624,7 +615,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getUsersUserGroups
    *        summary: /user-groups/{id}/users
    *        description: Get users related to the userGroup
    *        parameters:
@@ -677,7 +667,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getUnrelatedUsersUserGroups
    *        summary: /user-groups/{id}/unrelated-users
    *        description: Get users unrelated to the userGroup
    *        parameters:
@@ -760,7 +749,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: addUserUserGroups
    *        summary: /user-groups/{id}/users/{username}
    *        description: Add a user to the userGroup
    *        parameters:
@@ -832,7 +820,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: deleteUsersUserGroups
    *        summary: /user-groups/{id}/users/{username}
    *        description: remove a user from the userGroup
    *        parameters:
@@ -890,7 +877,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getUserGroupRelationsUserGroups
    *        summary: /user-groups/{id}/user-group-relations
    *        description: Get the user group relations for the userGroup
    *        parameters:
@@ -939,7 +925,6 @@ module.exports = (crowi) => {
    *        tags: [UserGroups]
    *        security:
    *          - cookieAuth: []
-   *        operationId: getPagesUserGroups
    *        summary: /user-groups/{id}/pages
    *        description: Get closed pages for the userGroup
    *        parameters:

+ 0 - 18
apps/app/src/server/routes/apiv3/users.js

@@ -241,7 +241,6 @@ module.exports = (crowi) => {
    *    /users:
    *      get:
    *        tags: [Users]
-   *        operationId: listUsers
    *        summary: /users
    *        description: Select selected columns from users order by asc or desc
    *        parameters:
@@ -377,7 +376,6 @@ module.exports = (crowi) => {
    *    /{id}/recent:
    *      get:
    *        tags: [Users]
-   *        operationId: recent created page of user id
    *        summary: /usersIdReacent
    *        parameters:
    *          - name: id
@@ -453,7 +451,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: inviteUser
    *        summary: /users/invite
    *        description: Create new users and send Emails
    *        parameters:
@@ -532,7 +529,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: grantAdminUser
    *        summary: /users/{id}/grant-admin
    *        description: Grant user admin
    *        parameters:
@@ -581,7 +577,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: revokeAdminUser
    *        summary: /users/{id}/revoke-admin
    *        description: Revoke user admin
    *        parameters:
@@ -630,7 +625,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: ReadOnly
    *        summary: /users/{id}/grant-read-only
    *        description: Grant user read only access
    *        parameters:
@@ -684,7 +678,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: revokeReadOnly
    *        summary: /users/{id}/revoke-read-only
    *        description: Revoke user read only access
    *        parameters:
@@ -738,7 +731,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: activateUser
    *        summary: /users/{id}/activate
    *        description: Activate user
    *        parameters:
@@ -794,7 +786,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: deactivateUser
    *        summary: /users/{id}/deactivate
    *        description: Deactivate user
    *        parameters:
@@ -843,7 +834,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: removeUser
    *        summary: /users/{id}/remove
    *        description: Delete user
    *        parameters:
@@ -907,7 +897,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: listExternalAccountsUsers
    *        summary: /users/external-accounts
    *        description: Get external-account
    *        parameters:
@@ -948,7 +937,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: removeExternalAccountUser
    *        summary: /users/external-accounts/{id}/remove
    *        description: Delete ExternalAccount
    *        parameters:
@@ -993,7 +981,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: update.imageUrlCache
    *        summary: /users/update.imageUrlCache
    *        description: update imageUrlCache
    *        requestBody:
@@ -1049,7 +1036,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: resetPassword
    *        summary: /users/reset-password
    *        description: update imageUrlCache
    *        requestBody:
@@ -1099,7 +1085,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: resetPasswordEmail
    *        summary: /users/reset-password-email
    *        description: send new password email
    *        requestBody:
@@ -1148,7 +1133,6 @@ module.exports = (crowi) => {
    *        tags: [Users Management]
    *        security:
    *          - cookieAuth: []
-   *        operationId: sendInvitationEmail
    *        summary: /users/send-invitation-email
    *        description: send invitation email
    *        requestBody:
@@ -1207,7 +1191,6 @@ module.exports = (crowi) => {
    *        get:
    *          tags: [Users]
    *          summary: /users/list
-   *          operationId: getUsersList
    *          description: Get list of users
    *          parameters:
    *            - in: query
@@ -1270,7 +1253,6 @@ module.exports = (crowi) => {
     *        get:
     *          tags: [Users]
     *          summary: /users/usernames
-    *          operationId: getUsernames
     *          description: Get list of usernames
     *          parameters:
     *            - in: query

+ 5 - 5
apps/app/src/server/routes/attachment/api.js

@@ -110,7 +110,7 @@ const ApiResponse = require('../../util/apiResponse');
  *            description: original file name
  *            example: profile.png
  *          creator:
- *            $ref: '#/components/schemas/User/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *          page:
  *            type: string
  *            description: page ID attached at
@@ -222,7 +222,7 @@ export const routesFactory = (crowi) => {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    attachment:
    *                      $ref: '#/components/schemas/AttachmentProfile'
    *          403:
@@ -289,7 +289,7 @@ export const routesFactory = (crowi) => {
    *              schema:
    *                properties:
    *                  attachment_id:
-   *                    $ref: '#/components/schemas/Attachment/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                required:
    *                  - attachment_id
    *        responses:
@@ -300,7 +300,7 @@ export const routesFactory = (crowi) => {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *          403:
    *            $ref: '#/components/responses/403'
    *          500:
@@ -365,7 +365,7 @@ export const routesFactory = (crowi) => {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *          403:
    *            $ref: '#/components/responses/403'
    *          500:

+ 29 - 27
apps/app/src/server/routes/comment.js

@@ -21,32 +21,34 @@ import { preNotifyService } from '../service/pre-notify';
  *
  *  components:
  *    schemas:
+ *      CommentBody:
+ *        description: The type for Comment.comment
+ *        type: string
+ *        example: good
+ *      CommentPosition:
+ *        description: comment position
+ *        type: number
+ *        example: 0
  *      Comment:
  *        description: Comment
  *        type: object
  *        properties:
  *          _id:
- *            type: string
- *            description: revision ID
- *            example: 5e079a0a0afa6700170a75fb
+ *            $ref: '#/components/schemas/ObjectId'
  *          __v:
  *            type: number
  *            description: DB record version
  *            example: 0
  *          page:
- *            $ref: '#/components/schemas/Page/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *          creator:
- *            $ref: '#/components/schemas/User/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *          revision:
- *            $ref: '#/components/schemas/Revision/properties/_id'
+ *            $ref: '#/components/schemas/ObjectId'
  *          comment:
- *            type: string
- *            description: comment
- *            example: good
+ *            $ref: '#/components/schemas/CommentBody'
  *          commentPosition:
- *            type: number
- *            description: comment position
- *            example: 0
+ *            $ref: '#/components/schemas/CommentPosition'
  *          createdAt:
  *            type: string
  *            description: date created at
@@ -88,11 +90,11 @@ module.exports = function(crowi, app) {
    *          - in: query
    *            name: page_id
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
+   *              $ref: '#/components/schemas/ObjectId'
    *          - in: query
    *            name: revision_id
    *            schema:
-   *              $ref: '#/components/schemas/Revision/properties/_id'
+   *              $ref: '#/components/schemas/ObjectId'
    *        responses:
    *          200:
    *            description: Succeeded to get comments of the page of the revision.
@@ -101,7 +103,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    comments:
    *                      type: array
    *                      items:
@@ -190,13 +192,13 @@ module.exports = function(crowi, app) {
    *                    type: object
    *                    properties:
    *                      page_id:
-   *                        $ref: '#/components/schemas/Page/properties/_id'
+   *                        $ref: '#/components/schemas/ObjectId'
    *                      revision_id:
-   *                        $ref: '#/components/schemas/Revision/properties/_id'
+   *                        $ref: '#/components/schemas/ObjectId'
    *                      comment:
-   *                        $ref: '#/components/schemas/Comment/properties/comment'
+   *                        $ref: '#/components/schemas/CommentBody'
    *                      comment_position:
-   *                        $ref: '#/components/schemas/Comment/properties/commentPosition'
+   *                        $ref: '#/components/schemas/CommentPosition'
    *                required:
    *                  - commentForm
    *        responses:
@@ -207,7 +209,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    comment:
    *                      $ref: '#/components/schemas/Comment'
    *          403:
@@ -336,13 +338,13 @@ module.exports = function(crowi, app) {
    *                        type: object
    *                        properties:
    *                          page_id:
-   *                            $ref: '#/components/schemas/Page/properties/_id'
+   *                            $ref: '#/components/schemas/ObjectId'
    *                          revision_id:
-   *                            $ref: '#/components/schemas/Revision/properties/_id'
+   *                            $ref: '#/components/schemas/ObjectId'
    *                          comment_id:
-   *                            $ref: '#/components/schemas/Comment/properties/_id'
+   *                            $ref: '#/components/schemas/ObjectId'
    *                          comment:
-   *                            $ref: '#/components/schemas/Comment/properties/comment'
+   *                            $ref: '#/components/schemas/CommentBody'
    *                required:
    *                  - form
    *        responses:
@@ -353,7 +355,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    comment:
    *                      $ref: '#/components/schemas/Comment'
    *          403:
@@ -433,7 +435,7 @@ module.exports = function(crowi, app) {
    *              schema:
    *                properties:
    *                  comment_id:
-   *                    $ref: '#/components/schemas/Comment/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                required:
    *                  - comment_id
    *        responses:
@@ -444,7 +446,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    comment:
    *                      $ref: '#/components/schemas/Comment'
    *          403:

+ 4 - 4
apps/app/src/server/routes/page.js

@@ -149,7 +149,7 @@ module.exports = function(crowi, app) {
    *          - in: query
    *            name: pageId
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
+   *              $ref: '#/components/schemas/ObjectId'
    *        responses:
    *          200:
    *            description: Succeeded to get page tags.
@@ -158,7 +158,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    tags:
    *                      $ref: '#/components/schemas/Tags'
    *          403:
@@ -197,7 +197,7 @@ module.exports = function(crowi, app) {
    *          - in: query
    *            name: path
    *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
+   *              $ref: '#/components/schemas/PagePath'
    *        responses:
    *          200:
    *            description: Succeeded to get UpdatePost setting list.
@@ -206,7 +206,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    updatePost:
    *                      $ref: '#/components/schemas/UpdatePost'
    *          403:

+ 22 - 20
apps/app/src/server/routes/search.ts

@@ -14,26 +14,28 @@ const logger = loggerFactory('growi:routes:search');
  *
  *   components:
  *     schemas:
+ *       ElasticsearchResultMeta:
+ *         type: object
+ *         properties:
+ *           took:
+ *             type: number
+ *             description: Time Elasticsearch took to execute a search(milliseconds)
+ *             example: 34
+ *           total:
+ *             type: number
+ *             description: Number of documents matching search criteria
+ *             example: 2
+ *           results:
+ *             type: number
+ *             description: Actual array length of search results
+ *             example: 2
+ *
  *       ElasticsearchResult:
  *         description: Elasticsearch result v1
  *         type: object
  *         properties:
  *           meta:
- *             type: object
- *             properties:
- *               took:
- *                 type: number
- *                 description: Time Elasticsearch took to execute a search(milliseconds)
- *                 example: 34
- *               total:
- *                 type: number
- *                 description: Number of documents matching search criteria
- *                 example: 2
- *               results:
- *                 type: number
- *                 description: Actual array length of search results
- *                 example: 2
- *
+ *             $ref: '#/components/schemas/ElasticsearchResultMeta'
  */
 module.exports = function(crowi: Crowi, app) {
   const ApiResponse = require('../util/apiResponse');
@@ -62,15 +64,15 @@ module.exports = function(crowi: Crowi, app) {
    *         - in: query
    *           name: path
    *           schema:
-   *             $ref: '#/components/schemas/Page/properties/path'
+   *             $ref: '#/components/schemas/PagePath'
    *         - in: query
    *           name: offset
    *           schema:
-   *             $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
+   *             $ref: '#/components/schemas/Offset'
    *         - in: query
    *           name: limit
    *           schema:
-   *             $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
+   *             $ref: '#/components/schemas/Limit'
    *       responses:
    *         200:
    *           description: Succeeded to get list of pages.
@@ -79,9 +81,9 @@ module.exports = function(crowi: Crowi, app) {
    *               schema:
    *                 properties:
    *                   ok:
-   *                     $ref: '#/components/schemas/V1Response/properties/ok'
+   *                     $ref: '#/components/schemas/V1ResponseOK'
    *                   meta:
-   *                     $ref: '#/components/schemas/ElasticsearchResult/properties/meta'
+   *                     $ref: '#/components/schemas/ElasticsearchResultMeta'
    *                   totalCount:
    *                     type: integer
    *                     description: total count of pages

+ 7 - 36
apps/app/src/server/routes/tag.js

@@ -5,35 +5,6 @@ import PageTagRelation from '../models/page-tag-relation';
 import { Revision } from '../models/revision';
 import ApiResponse from '../util/apiResponse';
 
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      Tags:
- *        description: Tags
- *        type: array
- *        items:
- *          $ref: '#/components/schemas/Tag/properties/name'
- *        example: ['daily', 'report', 'tips']
- *
- *      Tag:
- *        description: Tag
- *        type: object
- *        properties:
- *          _id:
- *            type: string
- *            description: tag ID
- *            example: 5e2d6aede35da4004ef7e0b7
- *          name:
- *            type: string
- *            description: tag name
- *            example: daily
- *          count:
- *            type: number
- *            description: Count of tagged pages
- *            example: 3
- */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = function(crowi, app) {
 
@@ -68,7 +39,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    tags:
    *                      $ref: '#/components/schemas/Tags'
    *          403:
@@ -109,9 +80,9 @@ module.exports = function(crowi, app) {
    *              schema:
    *                properties:
    *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  revisionId:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                    $ref: '#/components/schemas/ObjectId'
    *                  tags:
    *                    $ref: '#/components/schemas/Tags'
    *        responses:
@@ -122,7 +93,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    tags:
    *                      $ref: '#/components/schemas/Tags'
    *          403:
@@ -186,11 +157,11 @@ module.exports = function(crowi, app) {
    *          - in: query
    *            name: limit
    *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
+   *              $ref: '#/components/schemas/Limit'
    *          - in: query
    *            name: offset
    *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
+   *              $ref: '#/components/schemas/Offset'
    *        responses:
    *          200:
    *            description: Succeeded to tag list.
@@ -199,7 +170,7 @@ module.exports = function(crowi, app) {
    *                schema:
    *                  properties:
    *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                      $ref: '#/components/schemas/V1ResponseOK'
    *                    data:
    *                      type: array
    *                      items:

+ 18 - 14
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,6 +1,9 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
-import type { ConfigDefinition, Lang } from '@growi/core/dist/interfaces';
-import { defineConfig } from '@growi/core/dist/interfaces';
+import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces';
+import {
+  toNonBlankString,
+  defineConfig,
+} from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
 
 import { ActionGroupSize } from '~/interfaces/activity';
@@ -819,28 +822,29 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'S3_OBJECT_ACL',
     defaultValue: undefined,
   }),
-  'aws:s3Bucket': defineConfig<string>({
-    defaultValue: 'growi',
+  'aws:s3Bucket': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('growi'),
   }),
-  'aws:s3Region': defineConfig<string>({
-    defaultValue: 'ap-northeast-1',
+  'aws:s3Region': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('ap-northeast-1'),
   }),
-  'aws:s3AccessKeyId': defineConfig<string | undefined>({
+  'aws:s3AccessKeyId': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
   }),
-  'aws:s3SecretAccessKey': defineConfig<string | undefined>({
+  'aws:s3SecretAccessKey': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
+    isSecret: true,
   }),
-  'aws:s3CustomEndpoint': defineConfig<string | undefined>({
+  'aws:s3CustomEndpoint': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
   }),
 
   // GCS Settings
-  'gcs:apiKeyJsonPath': defineConfig<string | undefined>({
+  'gcs:apiKeyJsonPath': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_API_KEY_JSON_PATH',
     defaultValue: undefined,
   }),
-  'gcs:bucket': defineConfig<string | undefined>({
+  'gcs:bucket': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_BUCKET',
     defaultValue: undefined,
   }),
@@ -866,15 +870,15 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'AZURE_REFERENCE_FILE_WITH_RELAY_MODE',
     defaultValue: false,
   }),
-  'azure:tenantId': defineConfig<string | undefined>({
+  'azure:tenantId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_TENANT_ID',
     defaultValue: undefined,
   }),
-  'azure:clientId': defineConfig<string | undefined>({
+  'azure:clientId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_ID',
     defaultValue: undefined,
   }),
-  'azure:clientSecret': defineConfig<string | undefined>({
+  'azure:clientSecret': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_SECRET',
     defaultValue: undefined,
     isSecret: true,

+ 87 - 0
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -107,6 +107,55 @@ describe('ConfigManager', () => {
     });
   });
 
+  describe('updateConfig', () => {
+    beforeEach(async() => {
+      await Config.deleteMany({ key: /app.*/ }).exec();
+      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') });
+    });
+
+    test('updates a single config', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', 'updated value');
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
+    });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig).toBeNull(); // should be removed
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('initial value')); // should remain unchanged
+    });
+  });
+
   describe('updateConfigs', () => {
     beforeEach(async() => {
       await Config.deleteMany({ key: /app.*/ }).exec();
@@ -133,6 +182,44 @@ describe('ConfigManager', () => {
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      }, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1).toBeNull(); // should be removed
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
   });
 
   describe('removeConfigs', () => {

+ 104 - 4
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
   ConfigMock: {
     updateOne: vi.fn(),
     bulkWrite: vi.fn(),
+    deleteOne: vi.fn(),
   },
 }));
 vi.mock('../../models/config', () => ({
@@ -40,6 +41,9 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.updateOne.mockClear();
+      mocks.ConfigMock.deleteOne.mockClear();
     });
 
     test('invoke publishUpdateMessage()', async() => {
@@ -70,6 +74,42 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
 
+    test('remove config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' });
+      expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update config with undefined value when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledWith(
+        { key: 'app:siteUrl' },
+        { value: JSON.stringify(undefined) },
+        { upsert: true },
+      );
+      expect(mocks.ConfigMock.deleteOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
 
   describe('updateConfigs()', () => {
@@ -77,18 +117,20 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.bulkWrite.mockClear();
     });
 
     test('invoke publishUpdateMessage()', async() => {
-      // arrenge
+      // arrange
       configManager.publishUpdateMessage = vi.fn();
       vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
 
       // act
-      await configManager.updateConfig('app:siteUrl', '');
+      await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' });
 
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
@@ -102,10 +144,68 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
 
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
+
+    test('remove configs when values are undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs(
+        { 'app:siteUrl': undefined, 'app:title': 'GROWI' },
+        { removeIfUndefined: true },
+      );
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2);
+      expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update configs including undefined values when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' });
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2); // both operations should be included
+      expect(operations[0]).toEqual({
+        updateOne: {
+          filter: { key: 'app:siteUrl' },
+          update: { value: JSON.stringify(undefined) },
+          upsert: true,
+        },
+      });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
 
   describe('getManagedEnvVars()', () => {

+ 24 - 12
apps/app/src/server/service/config-manager/config-manager.ts

@@ -111,11 +111,17 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
 
-    await Config.updateOne(
-      { key },
-      { value: JSON.stringify(value) },
-      { upsert: true },
-    );
+    if (options?.removeIfUndefined && value === undefined) {
+      // remove the config if the value is undefined and removeIfUndefined is true
+      await Config.deleteOne({ key });
+    }
+    else {
+      await Config.updateOne(
+        { key },
+        { value: JSON.stringify(value) },
+        { upsert: true },
+      );
+    }
 
     await this.loadConfigs({ source: 'db' });
 
@@ -128,13 +134,19 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
 
-    const operations = Object.entries(updates).map(([key, value]) => ({
-      updateOne: {
-        filter: { key },
-        update: { value: JSON.stringify(value) },
-        upsert: true,
-      },
-    }));
+    const operations = Object.entries(updates).map(([key, value]) => {
+      return (options?.removeIfUndefined && value === undefined)
+        // remove the config if the value is undefined
+        ? { deleteOne: { filter: { key } } }
+        // update
+        : {
+          updateOne: {
+            filter: { key },
+            update: { value: JSON.stringify(value) },
+            upsert: true,
+          },
+        };
+    });
 
     await Config.bulkWrite(operations);
     await this.loadConfigs({ source: 'db' });

+ 9 - 5
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -13,6 +13,8 @@ import {
   AbortMultipartUploadCommand,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import type { NonBlankString } from '@growi/core/dist/interfaces';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
@@ -79,13 +81,15 @@ const getS3PutObjectCannedAcl = (): ObjectCannedACL | undefined => {
   return undefined;
 };
 
-const getS3Bucket = (): string | undefined => {
-  return configManager.getConfig('aws:s3Bucket') ?? undefined; // return undefined when getConfig() returns null
+const getS3Bucket = (): NonBlankString | undefined => {
+  return toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
 };
 
 const S3Factory = (): S3Client => {
   const accessKeyId = configManager.getConfig('aws:s3AccessKeyId');
   const secretAccessKey = configManager.getConfig('aws:s3SecretAccessKey');
+  const s3Region = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Region')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
+  const s3CustomEndpoint = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3CustomEndpoint'));
 
   return new S3Client({
     credentials: accessKeyId != null && secretAccessKey != null
@@ -94,9 +98,9 @@ const S3Factory = (): S3Client => {
         secretAccessKey,
       }
       : undefined,
-    region: configManager.getConfig('aws:s3Region'),
-    endpoint: configManager.getConfig('aws:s3CustomEndpoint'),
-    forcePathStyle: configManager.getConfig('aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
+    region: s3Region,
+    endpoint: s3CustomEndpoint,
+    forcePathStyle: s3CustomEndpoint != null, // s3ForcePathStyle renamed to forcePathStyle in v3
   });
 };
 

+ 4 - 3
apps/app/src/server/service/file-uploader/azure.ts

@@ -17,6 +17,7 @@ import {
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
 } from '@azure/storage-blob';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 
 import type Crowi from '~/server/crowi';
 import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -60,9 +61,9 @@ function getAzureConfig(): AzureConfig {
 }
 
 function getCredential(): TokenCredential {
-  const tenantId = configManager.getConfig('azure:tenantId');
-  const clientId = configManager.getConfig('azure:clientId');
-  const clientSecret = configManager.getConfig('azure:clientSecret');
+  const tenantId = toNonBlankStringOrUndefined(configManager.getConfig('azure:tenantId'));
+  const clientId = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientId'));
+  const clientSecret = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientSecret'));
 
   if (tenantId == null || clientId == null || clientSecret == null) {
     throw new Error(`Azure Blob Storage missing required configuration: tenantId=${tenantId}, clientId=${clientId}, clientSecret=${clientSecret}`);

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -2,6 +2,7 @@ import type { Readable } from 'stream';
 import { pipeline } from 'stream/promises';
 
 import { Storage } from '@google-cloud/storage';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import axios from 'axios';
 import urljoin from 'url-join';
 
@@ -24,7 +25,7 @@ const logger = loggerFactory('growi:service:fileUploaderGcs');
 
 
 function getGcsBucket(): string {
-  const gcsBucket = configManager.getConfig('gcs:bucket');
+  const gcsBucket = toNonBlankStringOrUndefined(configManager.getConfig('gcs:bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
   if (gcsBucket == null) {
     throw new Error('GCS bucket is not configured.');
   }
@@ -34,7 +35,7 @@ function getGcsBucket(): string {
 let storage: Storage;
 function getGcsInstance() {
   if (storage == null) {
-    const keyFilename = configManager.getConfig('gcs:apiKeyJsonPath');
+    const keyFilename = toNonBlankStringOrUndefined(configManager.getConfig('gcs:apiKeyJsonPath')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
     // see https://googleapis.dev/nodejs/storage/latest/Storage.html
     storage = keyFilename != null
       ? new Storage({ keyFilename }) // Create a client with explicit credentials

+ 1 - 11
apps/app/src/server/service/page/index.ts

@@ -82,7 +82,7 @@ export * from './page-service';
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath,
-  canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
+  isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -551,16 +551,6 @@ class PageService implements IPageService {
       return this.renamePageV4(page, newPagePath, user, options);
     }
 
-    if (options.isMoveMode) {
-      const fromPath = page.path;
-      const toPath = newPagePath;
-      const canMove = canMoveByPath(fromPath, toPath) && await Page.exists({ path: newPagePath });
-
-      if (!canMove) {
-        throw Error('Cannot move to this path.');
-      }
-    }
-
     const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, newPagePath);
     if (!canOperate) {
       throw Error(`Cannot operate rename to path "${newPagePath}" right now.`);

+ 6 - 0
apps/app/src/stores-universal/context.tsx

@@ -224,8 +224,14 @@ export const useLimitLearnablePageCountPerAssistant = (initialData?: number): SW
   return useContextSWR('limitLearnablePageCountPerAssistant', initialData);
 };
 
+
+export const useIsUsersHomepageDeletionEnabled = (initialData?: boolean): SWRResponse<boolean, false> => {
+  return useContextSWR('isUsersHomepageDeletionEnabled', initialData);
+};
+
 export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useSWRStatic<boolean, Error>('isEnableUnifiedMergeView', initialData, { fallbackData: false });
+
 };
 
 /** **********************************************************

+ 1 - 2
apps/app/src/stores/page-listing.tsx

@@ -7,7 +7,6 @@ import type {
 import useSWR, {
   mutate, type SWRConfiguration, type SWRResponse, type Arguments,
 } from 'swr';
-import { cache } from 'swr/_internal';
 import useSWRImmutable from 'swr/immutable';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRInfinite, { unstable_serialize } from 'swr/infinite'; // eslint-disable-line camelcase
@@ -16,7 +15,7 @@ import type { IPagingResult } from '~/interfaces/paging-result';
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import type {
-  AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
+  ChildrenResult, V5MigrationStatus, RootPageResult,
 } from '../interfaces/page-listing-results';
 
 

+ 1 - 1
apps/app/vitest.workspace.mts

@@ -15,7 +15,7 @@ const configShared = defineConfig({
       'test/**',
       'test-with-vite/**',
       'playwright/**',
-    ]
+    ],
   },
 });
 

+ 0 - 1
apps/pdf-converter/.eslintignore

@@ -1 +0,0 @@
-/dist/**

+ 6 - 0
apps/pdf-converter/.eslintrc.cjs

@@ -1,5 +1,11 @@
+/**
+ * @type {import('eslint').Linter.Config}
+ */
 module.exports = {
   extends: '../../.eslintrc.js',
+  ignorePatterns: [
+    'dist/**',
+  ],
   rules: {
     'no-useless-constructor': 'off',
     '@typescript-eslint/consistent-type-imports': 'off',

+ 11 - 7
apps/pdf-converter/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pdf-converter",
-  "version": "1.0.1-RC.0",
+  "version": "1.1.1-RC.0",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "license": "MIT",
@@ -12,10 +12,11 @@
     "start:prod:ci": "pnpm start:prod --ci",
     "start:prod": "node dist/index.js",
     "lint": "pnpm eslint **/*.{js,ts}",
-    "gen:swagger-spec": "SWAGGER_GENERATION=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
+    "gen:swagger-spec": "SKIP_PUPPETEER_INIT=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
     "build": "pnpm tsc -p tsconfig.build.json",
     "version:prerelease": "pnpm version prerelease --preid=RC",
-    "version:prepatch": "pnpm version prepatch --preid=RC"
+    "version:prepatch": "pnpm version prepatch --preid=RC",
+    "test": "SKIP_PUPPETEER_INIT=true vitest run"
   },
   "dependencies": {
     "@godaddy/terminus": "^4.12.1",
@@ -24,17 +25,17 @@
     "@tsed/common": "=8.5.0",
     "@tsed/components-scan": "=8.5.0",
     "@tsed/core": "=8.5.0",
-    "@tsed/engines": "=8.5.0",
     "@tsed/di": "=8.5.0",
+    "@tsed/engines": "=8.5.0",
     "@tsed/exceptions": "=8.5.0",
     "@tsed/json-mapper": "=8.5.0",
     "@tsed/logger": ">=7.0.1",
     "@tsed/platform-express": "=8.5.0",
+    "@tsed/platform-http": "=8.5.0",
     "@tsed/platform-views": "=8.5.0",
     "@tsed/schema": "=8.5.0",
     "@tsed/swagger": "=8.5.0",
     "@tsed/terminus": "=8.5.0",
-    "@tsed/platform-http": "=8.5.0",
     "axios": "^0.24.0",
     "express": "^4.19.2",
     "puppeteer": "^23.1.1",
@@ -42,11 +43,14 @@
     "tslib": "^2.8.0"
   },
   "devDependencies": {
+    "@swc-node/register": "^1.10.9",
+    "@swc/core": "^1.9.2",
     "@types/connect": "^3.4.38",
     "@types/express": "^4.17.21",
     "@types/multer": "^1.4.12",
     "@types/node": "^22.5.4",
-    "@swc-node/register": "^1.10.9",
-    "@swc/core": "^1.9.2"
+    "@types/supertest": "^6.0.3",
+    "supertest": "^7.1.1",
+    "unplugin-swc": "^1.5.3"
   }
 }

+ 55 - 0
apps/pdf-converter/src/controllers/pdf.spec.ts

@@ -0,0 +1,55 @@
+import { PlatformTest } from '@tsed/platform-http/testing';
+import SuperTest from 'supertest';
+
+import Server from '../server';
+
+import { JobStatus, JobStatusSharedWithGrowi } from 'src/service/pdf-convert';
+
+describe('PdfCtrl', () => {
+  beforeAll(PlatformTest.bootstrap(Server));
+  afterAll(PlatformTest.reset);
+
+  it('should return 500 for invalid appId', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '64d2fa8b2f9c1e4a9b5e3d77',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: '../../../admin/secret-dir',
+      })
+      .expect(500);
+  });
+
+  it('should return 400 for invalid jobId', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    const res = await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '../../../admin/secret-dir',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: 1,
+      })
+      .expect(400);
+
+    expect(res.body.message).toContain('jobId must be a valid MongoDB ObjectId');
+  });
+
+  it('should return 202 and status for valid request', async() => {
+    const request = SuperTest(PlatformTest.callback());
+    const res = await request
+      .post('/pdf/sync-job')
+      .send({
+        jobId: '64d2fa8b2f9c1e4a9b5e3d77',
+        expirationDate: '2024-01-01T00:00:00Z',
+        status: JobStatusSharedWithGrowi.HTML_EXPORT_IN_PROGRESS,
+        appId: 1,
+      })
+      .expect(202);
+
+    expect(res.body).toHaveProperty('status');
+    expect(Object.values(JobStatus)).toContain(res.body.status);
+  });
+});

+ 8 - 3
apps/pdf-converter/src/controllers/pdf.ts

@@ -1,9 +1,9 @@
 import { BodyParams } from '@tsed/common';
 import { Controller } from '@tsed/di';
-import { InternalServerError } from '@tsed/exceptions';
+import { InternalServerError, BadRequest } from '@tsed/exceptions';
 import { Logger } from '@tsed/logger';
 import {
-  Post, Returns, Enum, Description, Required,
+  Post, Returns, Enum, Description, Required, Integer,
 } from '@tsed/schema';
 
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
@@ -31,8 +31,13 @@ class PdfCtrl {
     @Required() @BodyParams('jobId') jobId: string,
     @Required() @BodyParams('expirationDate') expirationDateStr: string,
     @Required() @BodyParams('status') @Enum(Object.values(JobStatusSharedWithGrowi)) growiJobStatus: JobStatusSharedWithGrowi,
-    @BodyParams('appId') appId?: string,
+    @Integer() @BodyParams('appId') appId?: number, // prevent path traversal attack
   ): Promise<{ status: JobStatus } | undefined> {
+    // prevent path traversal attack
+    if (!/^[a-f\d]{24}$/i.test(jobId)) {
+      throw new BadRequest('jobId must be a valid MongoDB ObjectId');
+    }
+
     const expirationDate = new Date(expirationDateStr);
     try {
       await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus, appId);

+ 5 - 6
apps/pdf-converter/src/service/pdf-convert.ts

@@ -68,7 +68,7 @@ class PdfConvertService implements OnInit {
       jobId: string,
       expirationDate: Date,
       status: JobStatusSharedWithGrowi,
-      appId?: string,
+      appId?: number,
   ): Promise<void> {
     const isJobNew = !(jobId in this.jobList);
 
@@ -143,7 +143,7 @@ class PdfConvertService implements OnInit {
    * @param jobId PageBulkExportJob ID
    * @param appId application ID for GROWI.cloud
    */
-  private async readHtmlAndConvertToPdfUntilFinish(jobId: string, appId?: string): Promise<void> {
+  private async readHtmlAndConvertToPdfUntilFinish(jobId: string, appId?: number): Promise<void> {
     while (!this.isJobCompleted(jobId)) {
       // eslint-disable-next-line no-await-in-loop
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
@@ -176,8 +176,8 @@ class PdfConvertService implements OnInit {
    * @param appId application ID for GROWI.cloud
    * @returns readable stream
    */
-  private getHtmlReadable(jobId: string, appId?: string): Readable {
-    const jobHtmlDir = path.join(this.tmpHtmlDir, appId ?? '', jobId);
+  private getHtmlReadable(jobId: string, appId?: number): Readable {
+    const jobHtmlDir = path.join(this.tmpHtmlDir, appId?.toString() ?? '', jobId);
     const htmlFileEntries = fs.readdirSync(jobHtmlDir, { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
     let index = 0;
 
@@ -262,8 +262,7 @@ class PdfConvertService implements OnInit {
    * Initialize puppeteer cluster
    */
   private async initPuppeteerCluster(): Promise<void> {
-    // puppeteer is unnecessary for swagger schema generation
-    if (process.env.SWAGGER_GENERATION === 'true') return;
+    if (process.env.SKIP_PUPPETEER_INIT === 'true') return;
 
     this.puppeteerCluster = await Cluster.launch({
       concurrency: Cluster.CONCURRENCY_PAGE,

+ 2 - 1
apps/pdf-converter/tsconfig.json

@@ -8,7 +8,8 @@
     "esModuleInterop": true,
     "experimentalDecorators": true,
     "emitDecoratorMetadata": true,
-    "strict": true
+    "strict": true,
+    "types": ["vitest/globals"]
   },
   "include": ["./src/**/*", "./test/**/*"],
   "exclude": ["node_modules", "dist"]

+ 12 - 0
apps/pdf-converter/vitest.config.ts

@@ -0,0 +1,12 @@
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    root: './',
+  },
+  plugins: [
+    swc.vite(),
+  ],
+});

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.2.5-slackbot-proxy.0",
+  "version": "7.2.6-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 2 - 3
biome.json

@@ -29,8 +29,7 @@
       "./packages/preset-templates/**",
       "./packages/preset-themes/**",
       "./packages/remark-attachment-refs/**",
-      "./packages/remark-drawio/**",
-      "./packages/remark-growi-directive/**"
+      "./packages/remark-drawio/**"
     ]
   },
   "formatter": {
@@ -51,4 +50,4 @@
       "quoteStyle": "single"
     }
   }
-}
+}

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.2.5",
+  "version": "7.2.6-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 4 - 1
packages/core/src/interfaces/config-manager.ts

@@ -43,7 +43,10 @@ export type RawConfigData<K extends string, V extends Record<K, any>> = Record<K
   definition?: ConfigDefinition<V[K]>;
 }>;
 
-export type UpdateConfigOptions = { skipPubsub?: boolean };
+export type UpdateConfigOptions = {
+  skipPubsub?: boolean;
+  removeIfUndefined?: boolean;
+};
 
 /**
  * Interface for managing configuration values

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,3 +1,4 @@
+export * from './primitive/string';
 export * from './attachment';
 export * from './color-scheme';
 export * from './color-scheme';

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