瀏覽代碼

用户管理,租户管理强化

anhuiqiang 1 周之前
父節點
當前提交
c0fc95c565
共有 54 個文件被更改,包括 1513 次插入1664 次删除
  1. 14 4
      server/src/admin/admin.controller.ts
  2. 2 2
      server/src/admin/admin.service.ts
  3. 28 25
      server/src/api/api-v1.controller.ts
  4. 2 2
      server/src/api/api.module.ts
  5. 2 3
      server/src/app.module.ts
  6. 2 1
      server/src/auth/auth.service.ts
  7. 4 3
      server/src/auth/combined-auth.guard.ts
  8. 6 2
      server/src/auth/jwt.strategy.ts
  9. 5 1
      server/src/auth/local.strategy.ts
  10. 2 2
      server/src/chat/chat.module.ts
  11. 8 6
      server/src/chat/chat.service.ts
  12. 2 2
      server/src/data-source.ts
  13. 1 6
      server/src/knowledge-base/chunk-config.service.ts
  14. 0 2
      server/src/knowledge-base/knowledge-base.module.ts
  15. 11 7
      server/src/knowledge-base/knowledge-base.service.ts
  16. 29 0
      server/src/migrations/cleanup-settings-tables.sql
  17. 8 0
      server/src/migrations/restore-timestamps.sql
  18. 4 2
      server/src/rag/rag.module.ts
  19. 9 7
      server/src/rag/rag.service.ts
  20. 16 4
      server/src/super-admin/super-admin.controller.ts
  21. 5 5
      server/src/super-admin/super-admin.service.ts
  22. 3 4
      server/src/tenant/tenant-setting.entity.ts
  23. 22 5
      server/src/tenant/tenant.controller.ts
  24. 12 4
      server/src/tenant/tenant.entity.ts
  25. 38 15
      server/src/tenant/tenant.service.ts
  26. 0 85
      server/src/user-setting/dto/create-user-setting.dto.ts
  27. 0 5
      server/src/user-setting/dto/update-user-setting.dto.ts
  28. 0 15
      server/src/user-setting/dto/user-setting-response.dto.ts
  29. 0 115
      server/src/user-setting/user-setting.controller.ts
  30. 0 87
      server/src/user-setting/user-setting.entity.ts
  31. 0 16
      server/src/user-setting/user-setting.module.ts
  32. 0 169
      server/src/user-setting/user-setting.service.ts
  33. 7 3
      server/src/user/dto/create-user.dto.ts
  34. 7 4
      server/src/user/dto/update-user.dto.ts
  35. 2 1
      server/src/user/dto/user-safe.dto.ts
  36. 32 0
      server/src/user/user-setting.entity.ts
  37. 27 0
      server/src/user/user-setting.service.ts
  38. 46 34
      server/src/user/user.controller.ts
  39. 5 9
      server/src/user/user.entity.ts
  40. 5 3
      server/src/user/user.module.ts
  41. 46 34
      server/src/user/user.service.ts
  42. 7 2
      web/components/SettingsModal.tsx
  43. 7 19
      web/components/views/ChatView.tsx
  44. 1 3
      web/components/views/KnowledgeBaseView.tsx
  45. 791 797
      web/components/views/SettingsView.tsx
  46. 16 0
      web/services/apiClient.ts
  47. 21 23
      web/services/chatService.ts
  48. 11 14
      web/services/settingsService.ts
  49. 10 5
      web/services/userService.ts
  50. 23 70
      web/services/userSettingService.ts
  51. 78 24
      web/src/components/layouts/WorkspaceLayout.tsx
  52. 6 2
      web/src/contexts/AuthContext.tsx
  53. 31 0
      web/types.ts
  54. 99 11
      web/utils/translations.ts

+ 14 - 4
server/src/admin/admin.controller.ts

@@ -1,4 +1,4 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Request } from '@nestjs/common';
+import { Controller, Get, Post, Put, Body, UseGuards, Request, Query } from '@nestjs/common';
 import { AdminService } from './admin.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -7,13 +7,21 @@ import { UserRole } from '../user/user-role.enum';
 
 @Controller('v1/admin')
 @UseGuards(CombinedAuthGuard, RolesGuard)
-@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
 export class AdminController {
     constructor(private readonly adminService: AdminService) { }
 
     @Get('users')
-    async getUsers(@Request() req: any) {
-        return this.adminService.getTenantUsers(req.user.tenantId);
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+    async getUsers(
+        @Request() req: any,
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        return this.adminService.getTenantUsers(
+            req.user.tenantId,
+            page ? parseInt(page) : undefined,
+            limit ? parseInt(limit) : undefined
+        );
     }
 
     @Get('settings')
@@ -22,11 +30,13 @@ export class AdminController {
     }
 
     @Put('settings')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
     async updateSettings(@Request() req: any, @Body() body: any) {
         return this.adminService.updateTenantSettings(req.user.tenantId, body);
     }
 
     @Get('pending-shares')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
     async getPendingShares(@Request() req: any) {
         return this.adminService.getPendingShares(req.user.tenantId);
     }

+ 2 - 2
server/src/admin/admin.service.ts

@@ -9,8 +9,8 @@ export class AdminService {
         private readonly tenantService: TenantService,
     ) { }
 
-    async getTenantUsers(tenantId: string) {
-        return this.userService.findByTenantId(tenantId);
+    async getTenantUsers(tenantId: string, page?: number, limit?: number) {
+        return this.userService.findByTenantId(tenantId, page, limit);
     }
 
     async getTenantSettings(tenantId: string) {

+ 28 - 25
server/src/api/api-v1.controller.ts

@@ -18,7 +18,8 @@ import { RagService } from '../rag/rag.service';
 import { ChatService } from '../chat/chat.service';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
 import { ModelConfigService } from '../model-config/model-config.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user/user-setting.service';
 
 @Controller('v1')
 @UseGuards(ApiKeyGuard)
@@ -28,6 +29,7 @@ export class ApiV1Controller {
         private readonly chatService: ChatService,
         private readonly knowledgeBaseService: KnowledgeBaseService,
         private readonly modelConfigService: ModelConfigService,
+        private readonly tenantService: TenantService,
         private readonly userSettingService: UserSettingService,
     ) { }
 
@@ -56,10 +58,11 @@ export class ApiV1Controller {
             return res.status(400).json({ error: 'message is required' });
         }
 
-        // Get user settings and model configuration
-        const userSetting = await this.userSettingService.findOrCreate(user.id);
+        // Get organization settings and model configuration
+        const tenantSettings = await this.tenantService.getSettings(user.tenantId);
+        const userSetting = await this.userSettingService.getByUser(user.id);
         const models = await this.modelConfigService.findAll(user.id, user.tenantId);
-        const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
+        const llmModel = models.find((m) => m.id === tenantSettings?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
 
         if (!llmModel) {
             return res.status(400).json({ error: 'No LLM model configured for this user' });
@@ -79,19 +82,19 @@ export class ApiV1Controller {
                     user.id,
                     modelConfig,
                     userSetting?.language ?? 'zh',            // userLanguage
-                    userSetting?.selectedEmbeddingId,          // selectedEmbeddingId
+                    tenantSettings?.selectedEmbeddingId,      // selectedEmbeddingId
                     selectedGroups,                           // selectedGroups
                     selectedFiles,                            // selectedFiles
                     undefined,                                // historyId
-                    userSetting?.enableRerank ?? false,        // enableRerank
-                    userSetting?.selectedRerankId,             // selectedRerankId
-                    userSetting?.temperature,                  // temperature
-                    userSetting?.maxTokens,                    // maxTokens
-                    userSetting?.topK ?? 5,                    // topK
-                    userSetting?.similarityThreshold ?? 0.3,   // similarityThreshold
-                    userSetting?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
-                    userSetting?.enableQueryExpansion ?? false, // enableQueryExpansion
-                    userSetting?.enableHyDE ?? false,           // enableHyDE
+                    tenantSettings?.enableRerank ?? false,    // enableRerank
+                    tenantSettings?.selectedRerankId,         // selectedRerankId
+                    tenantSettings?.temperature,              // temperature
+                    tenantSettings?.maxTokens,                // maxTokens
+                    tenantSettings?.topK ?? 5,                // topK
+                    tenantSettings?.similarityThreshold ?? 0.3,   // similarityThreshold
+                    tenantSettings?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
+                    tenantSettings?.enableQueryExpansion ?? false, // enableQueryExpansion
+                    tenantSettings?.enableHyDE ?? false,           // enableHyDE
                     user.tenantId,                             // Passing tenantId correctly
                 );
 
@@ -117,19 +120,19 @@ export class ApiV1Controller {
                     user.id,
                     modelConfig,
                     userSetting?.language ?? 'zh',
-                    userSetting?.selectedEmbeddingId,
+                    tenantSettings?.selectedEmbeddingId,
                     selectedGroups,
                     selectedFiles,
                     undefined,                                // historyId
-                    userSetting?.enableRerank ?? false,
-                    userSetting?.selectedRerankId,
-                    userSetting?.temperature,
-                    userSetting?.maxTokens,
-                    userSetting?.topK ?? 5,
-                    userSetting?.similarityThreshold ?? 0.3,
-                    userSetting?.rerankSimilarityThreshold ?? 0.5,
-                    userSetting?.enableQueryExpansion ?? false,
-                    userSetting?.enableHyDE ?? false,
+                    tenantSettings?.enableRerank ?? false,
+                    tenantSettings?.selectedRerankId,
+                    tenantSettings?.temperature,
+                    tenantSettings?.maxTokens,
+                    tenantSettings?.topK ?? 5,
+                    tenantSettings?.similarityThreshold ?? 0.3,
+                    tenantSettings?.rerankSimilarityThreshold ?? 0.5,
+                    tenantSettings?.enableQueryExpansion ?? false,
+                    tenantSettings?.enableHyDE ?? false,
                     user.tenantId,                            // Passing tenantId correctly
                 );
 
@@ -169,7 +172,7 @@ export class ApiV1Controller {
 
         if (!query) return { error: 'query is required' };
 
-        const userSetting = await this.userSettingService.findOrCreate(user.id);
+        const userSetting = await this.tenantService.getSettings(user.tenantId);
 
         const results = await this.ragService.searchKnowledge(
             query,

+ 2 - 2
server/src/api/api.module.ts

@@ -4,8 +4,8 @@ import { ApiService } from './api.service';
 import { ApiV1Controller } from './api-v1.controller';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { AuthModule } from '../auth/auth.module';
+import { TenantModule } from '../tenant/tenant.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { RagModule } from '../rag/rag.module';
 import { ChatModule } from '../chat/chat.module';
 import { UserModule } from '../user/user.module';
@@ -17,10 +17,10 @@ import { memoryStorage } from 'multer';
     KnowledgeBaseModule,
     AuthModule,
     ModelConfigModule,
-    UserSettingModule,
     RagModule,
     ChatModule,
     UserModule,
+    TenantModule,
     MulterModule.register({ storage: memoryStorage() }),
   ],
   controllers: [ApiController, ApiV1Controller],

+ 2 - 3
server/src/app.module.ts

@@ -18,7 +18,6 @@ import { CombinedAuthGuard } from './auth/combined-auth.guard';
 import { KnowledgeBaseModule } from './knowledge-base/knowledge-base.module';
 import { ModelConfigModule } from './model-config/model-config.module';
 import { UserModule } from './user/user.module';
-import { UserSettingModule } from './user-setting/user-setting.module';
 import { TikaModule } from './tika/tika.module';
 import { VisionModule } from './vision/vision.module';
 import { LibreOfficeModule } from './libreoffice/libreoffice.module';
@@ -32,7 +31,7 @@ import { ImportTaskModule } from './import-task/import-task.module';
 import { I18nMiddleware } from './i18n/i18n.middleware';
 import { TenantMiddleware } from './tenant/tenant.middleware';
 import { User } from './user/user.entity';
-import { UserSetting } from './user-setting/user-setting.entity';
+import { UserSetting } from './user/user-setting.entity';
 import { ModelConfig } from './model-config/model-config.entity';
 import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
 import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
@@ -91,7 +90,6 @@ import { AdminModule } from './admin/admin.module';
     I18nModule,
     UserModule,
     TenantModule,
-    UserSettingModule,
     ModelConfigModule,
     KnowledgeBaseModule,
     KnowledgeGroupModule,
@@ -127,3 +125,4 @@ export class AppModule implements NestModule {
       .forRoutes('*');
   }
 }
+// Trigger restart correct

+ 2 - 1
server/src/auth/auth.service.ts

@@ -32,7 +32,8 @@ export class AuthService {
         id: user.id,
         username: user.username,
         role: user.role,
-        tenantId: user.tenantId
+        tenantId: user.tenantId,
+        displayName: user.displayName
       }
     };
   }

+ 4 - 3
server/src/auth/combined-auth.guard.ts

@@ -6,6 +6,7 @@ import { Request } from 'express';
 import { lastValueFrom, Observable } from 'rxjs';
 import { IS_PUBLIC_KEY } from './public.decorator';
 import { tenantStore } from '../tenant/tenant.store';
+import { UserRole } from '../user/user-role.enum';
 
 /**
  * A combined authentication guard that accepts either:
@@ -51,7 +52,7 @@ export class CombinedAuthGuard implements CanActivate {
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
 
-                    if (hasAccess || user.role === 'SUPER_ADMIN') {
+                    if (hasAccess || user.isAdmin) {
                         activeTenantId = requestedTenantId;
                     } else {
                         throw new UnauthorizedException('User does not belong to the requested tenant');
@@ -61,7 +62,7 @@ export class CombinedAuthGuard implements CanActivate {
                 request.user = {
                     id: user.id,
                     username: user.username,
-                    role: user.role,
+                    role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
                     tenantId: activeTenantId,
                 };
                 request.tenantId = activeTenantId;
@@ -99,7 +100,7 @@ export class CombinedAuthGuard implements CanActivate {
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
 
-                    if (hasAccess || user.role === 'SUPER_ADMIN') {
+                    if (hasAccess || user.isAdmin) {
                         user.tenantId = requestedTenantId;
                     } else {
                         throw new UnauthorizedException('User does not belong to the requested tenant');

+ 6 - 2
server/src/auth/jwt.strategy.ts

@@ -3,7 +3,8 @@ import { PassportStrategy } from '@nestjs/passport';
 import { ExtractJwt, Strategy } from 'passport-jwt';
 import { ConfigService } from '@nestjs/config';
 import { UserService } from '../user/user.service';
-import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
+import { SafeUser } from '../user/dto/user-safe.dto';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -31,9 +32,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
 
       // In a multi-tenant setup, the tenantId in the payload is the "default" or "last active" one.
       // But it can be overridden by the x-tenant-id header in the guard.
+      // Map the backend isAdmin flag to the global UserRole
+      const computedRole = result.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER;
+
       return {
         ...result,
-        role: payload.role || result.role,
+        role: payload.role || computedRole,
         tenantId: payload.tenantId || result.tenantId
       } as SafeUser;
     }

+ 5 - 1
server/src/auth/local.strategy.ts

@@ -4,6 +4,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
 import { I18nService } from '../i18n/i18n.service';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class LocalStrategy extends PassportStrategy(Strategy) {
@@ -20,6 +21,9 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
       throw new UnauthorizedException(this.i18nService.getMessage('incorrectCredentials'));
     }
     const { password: userPassword, ...result } = user; // Destructure to remove password
-    return result as SafeUser;
+    return {
+      ...result,
+      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER
+    } as SafeUser;
   }
 }

+ 2 - 2
server/src/chat/chat.module.ts

@@ -4,22 +4,22 @@ import { ChatService } from './chat.service';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 import { SearchHistoryModule } from '../search-history/search-history.module';
 import { RagModule } from '../rag/rag.module';
 import { TenantModule } from '../tenant/tenant.module';
+import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
     forwardRef(() => ElasticsearchModule),
     forwardRef(() => KnowledgeBaseModule),
     ModelConfigModule,
-    UserSettingModule,
     forwardRef(() => KnowledgeGroupModule),
     SearchHistoryModule,
     RagModule,
     TenantModule,
+    UserModule,
   ],
   controllers: [ChatController],
   providers: [ChatService],

+ 8 - 6
server/src/chat/chat.service.ts

@@ -12,7 +12,8 @@ import { RagService } from '../rag/rag.service';
 
 import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
 import { I18nService } from '../i18n/i18n.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user/user-setting.service';
 
 export interface ChatMessage {
   role: 'user' | 'assistant';
@@ -35,6 +36,7 @@ export class ChatService {
     private configService: ConfigService,
     private ragService: RagService,
     private i18nService: I18nService,
+    private tenantService: TenantService,
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
@@ -436,10 +438,10 @@ ${instruction}`;
         model: `${config.name} (${config.modelId})`,
         user: userId
       }, 'ja'));
-      const settings = await this.userSettingService.findOrCreate(userId);
+      const settings = await this.tenantService.getSettings(tenantId || 'default');
       const llm = new ChatOpenAI({
         apiKey: config.apiKey || 'ollama',
-        temperature: settings.temperature ?? 0.7, // ユーザー設定またはデフォルトを使用
+        temperature: settings.temperature ?? 0.7,
         modelName: config.modelId,
         configuration: {
           baseURL: config.baseUrl || 'http://localhost:11434/v1',
@@ -476,9 +478,9 @@ ${instruction}`;
         return null;
       }
 
-      // ユーザ設定から言語を取得
-      const settings = await this.userSettingService.findOrCreate(userId);
-      const language = settings.language || 'ja';
+      // ユーザ設定から言語を取得
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const language = userSettings?.language || 'ja';
 
       // プロンプトを構築
       const prompt = this.i18nService.getChatTitlePrompt(language, userMessage, aiResponse);

+ 2 - 2
server/src/data-source.ts

@@ -1,6 +1,6 @@
 import { DataSource } from 'typeorm';
 import { User } from './user/user.entity';
-import { UserSetting } from './user-setting/user-setting.entity';
+// import { UserSetting } from './user-setting/user-setting.entity';
 import { ModelConfig } from './model-config/model-config.entity';
 import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
 import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
@@ -19,7 +19,7 @@ export const AppDataSource = new DataSource({
     logging: true,
     entities: [
         User,
-        UserSetting,
+        // UserSetting,
         ModelConfig,
         KnowledgeBase,
         KnowledgeGroup,

+ 1 - 6
server/src/knowledge-base/chunk-config.service.ts

@@ -2,7 +2,7 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { TenantService } from '../tenant/tenant.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+// import { UserSettingService } from '../user-setting/user-setting.service';
 
 /**
  * チャンク設定サービス
@@ -48,7 +48,6 @@ export class ChunkConfigService {
     private modelConfigService: ModelConfigService,
     private i18nService: I18nService,
     private tenantService: TenantService,
-    private userSettingService: UserSettingService,
   ) {
     // 環境変数からグローバルな上限設定を読み込む
     this.envMaxChunkSize = parseInt(
@@ -361,10 +360,6 @@ export class ChunkConfigService {
       const tenantSettings = await this.tenantService.getSettings(tenantId);
       if (tenantSettings.chunkSize) defaultChunkSize = tenantSettings.chunkSize;
       if (tenantSettings.chunkOverlap) defaultOverlapSize = tenantSettings.chunkOverlap;
-    } else {
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      if (userSettings.chunkSize) defaultChunkSize = userSettings.chunkSize;
-      if (userSettings.chunkOverlap) defaultOverlapSize = userSettings.chunkOverlap;
     }
 
     return {

+ 0 - 2
server/src/knowledge-base/knowledge-base.module.ts

@@ -11,7 +11,6 @@ import { EmbeddingService } from './embedding.service';
 import { TextChunkerService } from './text-chunker.service';
 import { RagModule } from '../rag/rag.module';
 import { VisionModule } from '../vision/vision.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { MemoryMonitorService } from './memory-monitor.service';
 import { ChunkConfigService } from './chunk-config.service';
 import { LibreOfficeModule } from '../libreoffice/libreoffice.module';
@@ -31,7 +30,6 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
     ModelConfigModule,
     forwardRef(() => RagModule),
     VisionModule,
-    UserSettingModule,
     LibreOfficeModule,
     Pdf2ImageModule,
     VisionPipelineModule,

+ 11 - 7
server/src/knowledge-base/knowledge-base.service.ts

@@ -14,7 +14,7 @@ import { TextChunkerService } from './text-chunker.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { RagService } from '../rag/rag.service';
 import { VisionService } from '../vision/vision.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
 import { MemoryMonitorService } from './memory-monitor.service';
 import { ChunkConfigService } from './chunk-config.service';
 import { VisionPipelineService } from '../vision-pipeline/vision-pipeline.service';
@@ -22,6 +22,7 @@ import { LibreOfficeService } from '../libreoffice/libreoffice.service';
 import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
 import { DOC_EXTENSIONS, IMAGE_EXTENSIONS } from '../common/file-support.constants';
 import { ChatService } from '../chat/chat.service';
+import { UserSettingService } from '../user/user-setting.service';
 
 @Injectable()
 export class KnowledgeBaseService {
@@ -41,7 +42,7 @@ export class KnowledgeBaseService {
     @Inject(forwardRef(() => RagService))
     private ragService: RagService,
     private visionService: VisionService,
-    private userSettingService: UserSettingService,
+    private tenantService: TenantService,
     private memoryMonitor: MemoryMonitorService,
     private chunkConfigService: ChunkConfigService,
     private visionPipelineService: VisionPipelineService,
@@ -51,6 +52,7 @@ export class KnowledgeBaseService {
     private i18nService: I18nService,
     @Inject(forwardRef(() => ChatService))
     private chatService: ChatService,
+    private userSettingService: UserSettingService,
   ) { }
 
   async createAndIndex(
@@ -374,7 +376,8 @@ export class KnowledgeBaseService {
 
     // 画像ファイルの場合はビジョンモデルを使用
     if (this.visionService.isImageFile(kb.mimetype)) {
-      const visionModelId = await this.userSettingService.getVisionModelId(userId);
+      const settings = await this.tenantService.getSettings(tenantId || 'default');
+      const visionModelId = settings.selectedVisionId;
       if (visionModelId) {
         const visionModel = await this.modelConfigService.findOne(
           visionModelId,
@@ -437,7 +440,8 @@ export class KnowledgeBaseService {
     }
 
     // Vision モデルが設定されているか確認
-    const visionModelId = await this.userSettingService.getVisionModelId(userId);
+      const settings = await this.tenantService.getSettings(tenantId || 'default');
+    const visionModelId = settings.selectedVisionId;
     if (!visionModelId) {
       this.logger.warn(
         this.i18nService.getMessage('visionModelNotConfiguredFallback')
@@ -1425,9 +1429,9 @@ export class KnowledgeBaseService {
       // コンテンツの冒頭サンプルを取得(最大2500文字)
       const contentSample = kb.content.substring(0, 2500);
 
-      // ユーザー設定から言語を取得、またはデフォルトを使用
-      const settings = await this.userSettingService.findOrCreate(kb.userId);
-      const language = settings.language || 'ja';
+      // 組織設定から言語を取得、またはデフォルトを使用
+      const userSettings = await this.userSettingService.getByUser(kb.userId);
+      const language = userSettings.language || 'zh';
 
       // プロンプトを構築
       const prompt = this.i18nService.getDocumentTitlePrompt(language, contentSample);

+ 29 - 0
server/src/migrations/cleanup-settings-tables.sql

@@ -0,0 +1,29 @@
+-- cleanup-settings-tables.sql
+-- Drop unnecessary columns from settings tables to align with the refined architecture.
+
+-- 1. Prune user_settings table
+-- Keeps only id, userId, and language.
+ALTER TABLE user_settings DROP COLUMN selectedLLMId;
+ALTER TABLE user_settings DROP COLUMN selectedEmbeddingId;
+ALTER TABLE user_settings DROP COLUMN selectedRerankId;
+ALTER TABLE user_settings DROP COLUMN temperature;
+ALTER TABLE user_settings DROP COLUMN maxTokens;
+ALTER TABLE user_settings DROP COLUMN enableRerank;
+ALTER TABLE user_settings DROP COLUMN topK;
+ALTER TABLE user_settings DROP COLUMN similarityThreshold;
+ALTER TABLE user_settings DROP COLUMN enableFullTextSearch;
+ALTER TABLE user_settings DROP COLUMN defaultVisionModelId;
+ALTER TABLE user_settings DROP COLUMN coachKbId;
+ALTER TABLE user_settings DROP COLUMN created_at;
+ALTER TABLE user_settings DROP COLUMN updated_at;
+ALTER TABLE user_settings DROP COLUMN rerankSimilarityThreshold;
+ALTER TABLE user_settings DROP COLUMN hybridVectorWeight;
+ALTER TABLE user_settings DROP COLUMN isGlobal;
+ALTER TABLE user_settings DROP COLUMN enableQueryExpansion;
+ALTER TABLE user_settings DROP COLUMN enableHyDE;
+ALTER TABLE user_settings DROP COLUMN chunkSize;
+ALTER TABLE user_settings DROP COLUMN chunkOverlap;
+
+-- 2. Prune tenant_settings table
+-- Language is now strictly a user-level setting.
+ALTER TABLE tenant_settings DROP COLUMN language;

+ 8 - 0
server/src/migrations/restore-timestamps.sql

@@ -0,0 +1,8 @@
+-- restore-timestamps.sql
+-- Restore created_at and updated_at columns to user_settings table.
+
+ALTER TABLE user_settings ADD COLUMN created_at datetime;
+ALTER TABLE user_settings ADD COLUMN updated_at datetime;
+
+UPDATE user_settings SET created_at = datetime('now') WHERE created_at IS NULL;
+UPDATE user_settings SET updated_at = datetime('now') WHERE updated_at IS NULL;

+ 4 - 2
server/src/rag/rag.module.ts

@@ -3,7 +3,8 @@ import { RagService } from './rag.service';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
+import { TenantModule } from '../tenant/tenant.module';
+import { UserModule } from '../user/user.module';
 
 import { RerankService } from './rerank.service';
 
@@ -11,7 +12,8 @@ import { RerankService } from './rerank.service';
   imports: [
     forwardRef(() => ElasticsearchModule),
     ModelConfigModule,
-    UserSettingModule,
+    TenantModule,
+    UserModule,
   ],
   providers: [RagService, EmbeddingService, RerankService],
   exports: [RagService],

+ 9 - 7
server/src/rag/rag.service.ts

@@ -5,9 +5,10 @@ import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { RerankService } from './rerank.service';
 import { I18nService } from '../i18n/i18n.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
 import { ChatOpenAI } from '@langchain/openai';
 import { ModelConfig } from '../types';
+import { UserSettingService } from '../user/user-setting.service';
 
 export interface RagSearchResult {
   content: string;
@@ -34,6 +35,7 @@ export class RagService {
     private rerankService: RerankService,
     private configService: ConfigService,
     private i18nService: I18nService,
+    private tenantService: TenantService,
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
@@ -58,8 +60,8 @@ export class RagService {
     enableQueryExpansion?: boolean,
     enableHyDE?: boolean,
   ): Promise<RagSearchResult[]> {
-    // 1. グローバル設定の取得
-    const globalSettings = await this.userSettingService.getGlobalSettings();
+    // 1. 組織設定の取得
+    const globalSettings = await this.tenantService.getSettings(tenantId || 'default');
 
     // パラメータが明示的に渡されていない場合はグローバル設定を使用
     const effectiveTopK = topK || globalSettings.topK || 5;
@@ -294,8 +296,8 @@ ${answerHeader}`;
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return [query];
 
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      const lang = userSettings.language || 'ja';
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const lang = userSettings.language || 'zh';
       const prompt = this.i18nService.formatMessage('queryExpansionPrompt', { query }, lang);
 
       const response = await llm.invoke(prompt);
@@ -323,8 +325,8 @@ ${answerHeader}`;
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return query;
 
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      const lang = userSettings.language || 'ja';
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const lang = userSettings.language || 'zh';
       const prompt = this.i18nService.formatMessage('hydePrompt', { query }, lang);
 
       const response = await llm.invoke(prompt);

+ 16 - 4
server/src/super-admin/super-admin.controller.ts

@@ -1,4 +1,4 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete, HttpCode } from '@nestjs/common';
+import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete, HttpCode, Patch, ForbiddenException } from '@nestjs/common';
 import { SuperAdminService } from './super-admin.service';
 import { TenantService } from '../tenant/tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -21,8 +21,8 @@ export class SuperAdminController {
     }
 
     @Post()
-    async createTenant(@Body() body: { name: string; domain?: string; adminUserId?: string }) {
-        return this.superAdminService.createTenant(body.name, body.domain, body.adminUserId);
+    async createTenant(@Body() body: { name: string; domain?: string; adminUserId?: string; parentId?: string }) {
+        return this.superAdminService.createTenant(body.name, body.domain, body.adminUserId, body.parentId);
     }
 
     @Put(':tenantId/admin')
@@ -44,7 +44,7 @@ export class SuperAdminController {
     @Put(':tenantId')
     async updateTenant(
         @Param('tenantId') tenantId: string,
-        @Body() body: { name?: string; domain?: string }
+        @Body() body: { name?: string; domain?: string; parentId?: string }
     ) {
         return this.superAdminService.updateTenant(tenantId, body);
     }
@@ -77,4 +77,16 @@ export class SuperAdminController {
     ) {
         await this.tenantService.removeMember(tenantId, userId);
     }
+
+    @Patch(':tenantId/members/:userId')
+    async updateMemberRole(
+        @Param('tenantId') tenantId: string,
+        @Param('userId') userId: string,
+        @Body() body: { role: string },
+    ) {
+        if (body.role !== UserRole.USER && body.role !== UserRole.TENANT_ADMIN) {
+            throw new ForbiddenException('Invalid role. Only USER and TENANT_ADMIN are allowed.');
+        }
+        return this.tenantService.updateMemberRole(tenantId, userId, body.role);
+    }
 }

+ 5 - 5
server/src/super-admin/super-admin.service.ts

@@ -15,8 +15,8 @@ export class SuperAdminService {
         return this.tenantService.findAll();
     }
 
-    async createTenant(name: string, domain?: string, adminUserId?: string) {
-        const tenant = await this.tenantService.create(name, domain);
+    async createTenant(name: string, domain?: string, adminUserId?: string, parentId?: string) {
+        const tenant = await this.tenantService.create(name, domain, parentId);
         if (adminUserId) {
             await this.tenantService.addMember(tenant.id, adminUserId, UserRole.TENANT_ADMIN);
         }
@@ -28,7 +28,7 @@ export class SuperAdminService {
         const members = await this.tenantService.getMembers(tenantId);
 
         // Remove existing admins from this tenant (unlinking them, not changing their role)
-        for (const member of members) {
+        for (const member of members.data) {
             if (member.role === UserRole.TENANT_ADMIN || member.role === UserRole.SUPER_ADMIN) {
                 await this.tenantService.removeMember(tenantId, member.userId);
             }
@@ -45,7 +45,7 @@ export class SuperAdminService {
             defaultPassword,
             false, // isAdmin
             tenantId,
-            UserRole.TENANT_ADMIN
+            username // displayName
         );
         return {
             user: result.user,
@@ -53,7 +53,7 @@ export class SuperAdminService {
         };
     }
 
-    async updateTenant(tenantId: string, data: { name?: string; domain?: string }) {
+    async updateTenant(tenantId: string, data: { name?: string; domain?: string; parentId?: string }) {
         return this.tenantService.update(tenantId, data);
     }
 

+ 3 - 4
server/src/tenant/tenant-setting.entity.ts

@@ -25,10 +25,6 @@ export class TenantSetting {
     @JoinColumn({ name: 'tenantId' })
     tenant: Tenant;
 
-    // Default language for the entire organization
-    @Column({ type: 'text', default: 'zh' })
-    language: string;
-
     // Default LLM model (override per user in UserSetting)
     @Column({ type: 'text', nullable: true })
     selectedLLMId: string;
@@ -41,6 +37,9 @@ export class TenantSetting {
     @Column({ type: 'text', nullable: true })
     selectedRerankId: string;
 
+    @Column({ type: 'text', nullable: true })
+    selectedVisionId: string;
+
     // Search configuration defaults
     @Column({ type: 'real', default: 0.3 })
     similarityThreshold: number;

+ 22 - 5
server/src/tenant/tenant.controller.ts

@@ -5,11 +5,13 @@ import {
     ForbiddenException,
     Get,
     Param,
+    Patch,
     Post,
     Put,
     Request,
     UseGuards,
     HttpCode,
+    Query,
 } from '@nestjs/common';
 import { TenantService } from './tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -31,12 +33,12 @@ export class TenantController {
     }
 
     @Post()
-    create(@Body() body: { name: string; description?: string }) {
-        return this.tenantService.create(body.name, body.description);
+    create(@Body() body: { name: string; domain?: string; parentId?: string }) {
+        return this.tenantService.create(body.name, body.domain, body.parentId);
     }
 
     @Put(':id')
-    update(@Param('id') id: string, @Body() body: { name?: string; description?: string; isActive?: boolean }) {
+    update(@Param('id') id: string, @Body() body: { name?: string; domain?: string; parentId?: string; isActive?: boolean }) {
         return this.tenantService.update(id, body);
     }
 
@@ -56,8 +58,14 @@ export class TenantController {
     }
 
     @Get(':id/members')
-    getMembers(@Param('id') id: string) {
-        return this.tenantService.getMembers(id);
+    getMembers(
+        @Param('id') id: string,
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        const p = page ? parseInt(page) : undefined;
+        const l = limit ? parseInt(limit) : undefined;
+        return this.tenantService.getMembers(id, p, l);
     }
 
     @Post(':id/members')
@@ -68,6 +76,15 @@ export class TenantController {
         return this.tenantService.addMember(id, body.userId, body.role);
     }
 
+    @Patch(':id/members/:userId')
+    async updateMemberRole(
+        @Param('id') id: string,
+        @Param('userId') userId: string,
+        @Body() body: { role: string },
+    ) {
+        return this.tenantService.updateMemberRole(id, userId, body.role);
+    }
+
     @Delete(':id/members/:userId')
     @HttpCode(204)
     async removeMember(

+ 12 - 4
server/src/tenant/tenant.entity.ts

@@ -8,6 +8,7 @@ import {
 } from 'typeorm';
 import { User } from '../user/user.entity';
 import { TenantMember } from './tenant-member.entity';
+import { JoinColumn, ManyToOne } from 'typeorm';
 
 @Entity('tenants')
 export class Tenant {
@@ -20,11 +21,18 @@ export class Tenant {
     @Column({ type: 'text', unique: true, nullable: true })
     domain: string;
 
-    @Column({ type: 'text', default: '{}' })
-    settings: string;
+    @Column({ type: 'boolean', default: false })
+    isSystem: boolean;
 
-    @Column({ name: 'default_model_id', type: 'text', nullable: true })
-    defaultModelId: string;
+    @Column({ name: 'parent_id', type: 'text', nullable: true })
+    parentId: string;
+
+    @ManyToOne(() => Tenant, (tenant) => tenant.children, { onDelete: 'SET NULL' })
+    @JoinColumn({ name: 'parent_id' })
+    parent: Tenant;
+
+    @OneToMany(() => Tenant, (tenant) => tenant.parent)
+    children: Tenant[];
 
     @OneToMany(() => TenantMember, (member) => member.tenant)
     members: TenantMember[];

+ 38 - 15
server/src/tenant/tenant.service.ts

@@ -40,11 +40,11 @@ export class TenantService {
         return this.tenantRepository.findOneBy({ name });
     }
 
-    async create(name: string, domain?: string): Promise<Tenant> {
+    async create(name: string, domain?: string, parentId?: string, isSystem: boolean = false): Promise<Tenant> {
         const existing = await this.findByName(name);
         if (existing) throw new BadRequestException(`Tenant name "${name}" already exists`);
 
-        const tenant = this.tenantRepository.create({ name, domain, settings: '{}' });
+        const tenant = this.tenantRepository.create({ name, domain, parentId, isSystem });
         const saved = await this.tenantRepository.save(tenant);
 
         // Auto-create default TenantSettings
@@ -56,17 +56,17 @@ export class TenantService {
 
     async update(id: string, data: Partial<Tenant>): Promise<Tenant> {
         const tenant = await this.findById(id);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot modify the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        if (tenant.isSystem) {
+            throw new ForbiddenException(`Cannot modify a system organization`);
         }
-        await this.tenantRepository.update(id, data);
+        await this.tenantRepository.save({ ...tenant, ...data });
         return this.findById(id);
     }
 
     async remove(id: string): Promise<void> {
         const tenant = await this.findById(id);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot delete the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        if (tenant.isSystem) {
+            throw new ForbiddenException(`Cannot delete a system organization`);
         }
         await this.tenantRepository.delete(id);
     }
@@ -110,10 +110,19 @@ export class TenantService {
         return this.tenantSettingRepository.save(setting);
     }
 
+    async updateMemberRole(tenantId: string, userId: string, role: string): Promise<TenantMember> {
+        const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
+        if (!existing) {
+            throw new ForbiddenException(`Member not found in this organization`);
+        }
+        existing.role = role as any;
+        return this.tenantMemberRepository.save(existing);
+    }
+
     async addMember(tenantId: string, userId: string, role: string = 'USER'): Promise<TenantMember> {
         const tenant = await this.findById(tenantId);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot manually bind members to the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        if (tenant.isSystem) {
+            throw new ForbiddenException(`Cannot manually bind members to a system organization`);
         }
         const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
         if (existing) {
@@ -128,11 +137,22 @@ export class TenantService {
         await this.tenantMemberRepository.delete({ tenantId, userId });
     }
 
-    async getMembers(tenantId: string): Promise<TenantMember[]> {
-        return this.tenantMemberRepository.find({
-            where: { tenantId },
-            relations: ['user'],
-        });
+    async getMembers(tenantId: string, page?: number, limit?: number): Promise<{ data: TenantMember[]; total: number }> {
+        const queryBuilder = this.tenantMemberRepository.createQueryBuilder('member')
+            .leftJoinAndSelect('member.user', 'user')
+            .where('member.tenantId = :tenantId', { tenantId })
+            .select(['member', 'user.id', 'user.username', 'user.displayName', 'user.isAdmin']);
+
+        if (page && limit) {
+            const [data, total] = await queryBuilder
+                .skip((page - 1) * limit)
+                .take(limit)
+                .getManyAndCount();
+            return { data, total };
+        }
+
+        const [data, total] = await queryBuilder.getManyAndCount();
+        return { data, total };
     }
 
     /**
@@ -142,7 +162,10 @@ export class TenantService {
     async ensureDefaultTenant(): Promise<Tenant> {
         let defaultTenant = await this.findByName(TenantService.DEFAULT_TENANT_NAME);
         if (!defaultTenant) {
-            defaultTenant = await this.create(TenantService.DEFAULT_TENANT_NAME, 'default.localhost');
+            defaultTenant = await this.create(TenantService.DEFAULT_TENANT_NAME, 'default.localhost', undefined, true);
+        } else if (!defaultTenant.isSystem) {
+            defaultTenant.isSystem = true;
+            await this.tenantRepository.save(defaultTenant);
         }
         return defaultTenant;
     }

+ 0 - 85
server/src/user-setting/dto/create-user-setting.dto.ts

@@ -1,85 +0,0 @@
-// server/src/user-setting/dto/create-user-setting.dto.ts
-import {
-  IsBoolean,
-  IsNotEmpty,
-  IsNumber,
-  IsOptional,
-  IsString,
-  Max,
-  Min,
-} from 'class-validator';
-import { DEFAULT_SETTINGS } from '../../defaults'; // Import default settings for validation min/max
-
-export class CreateUserSettingDto {
-  @IsString()
-  selectedLLMId: string = DEFAULT_SETTINGS.selectedLLMId;
-
-  @IsString()
-  selectedEmbeddingId: string = DEFAULT_SETTINGS.selectedEmbeddingId;
-
-  @IsString()
-  @IsOptional()
-  selectedRerankId?: string = DEFAULT_SETTINGS.selectedRerankId;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  temperature: number = DEFAULT_SETTINGS.temperature;
-
-  @IsNumber()
-  @Min(1) // Assuming min 1 token
-  maxTokens: number = DEFAULT_SETTINGS.maxTokens;
-
-  @IsBoolean()
-  enableRerank: boolean = DEFAULT_SETTINGS.enableRerank;
-
-  @IsNumber()
-  @Min(1)
-  topK: number = DEFAULT_SETTINGS.topK;
-
-  @IsNumber()
-  @Min(0) // Score threshold usually 0 to 1
-  @Max(1)
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  similarityThreshold: number = DEFAULT_SETTINGS.similarityThreshold;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  rerankSimilarityThreshold: number = DEFAULT_SETTINGS.rerankSimilarityThreshold;
-
-  @IsBoolean()
-  @IsOptional()
-  enableFullTextSearch: boolean = DEFAULT_SETTINGS.enableFullTextSearch;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  hybridVectorWeight: number = DEFAULT_SETTINGS.hybridVectorWeight;
-
-  @IsBoolean()
-  @IsOptional()
-  enableQueryExpansion: boolean = DEFAULT_SETTINGS.enableQueryExpansion;
-
-  @IsBoolean()
-  @IsOptional()
-  enableHyDE: boolean = DEFAULT_SETTINGS.enableHyDE;
-
-  @IsNumber()
-  @IsOptional()
-  chunkSize: number = DEFAULT_SETTINGS.chunkSize;
-
-  @IsNumber()
-  @IsOptional()
-  chunkOverlap: number = DEFAULT_SETTINGS.chunkOverlap;
-
-  @IsString()
-  @IsOptional()
-  coachKbId?: string;
-}

+ 0 - 5
server/src/user-setting/dto/update-user-setting.dto.ts

@@ -1,5 +0,0 @@
-// server/src/user-setting/dto/update-user-setting.dto.ts
-import { PartialType } from '@nestjs/mapped-types';
-import { CreateUserSettingDto } from './create-user-setting.dto';
-
-export class UpdateUserSettingDto extends PartialType(CreateUserSettingDto) {}

+ 0 - 15
server/src/user-setting/dto/user-setting-response.dto.ts

@@ -1,15 +0,0 @@
-// server/src/user-setting/dto/user-setting-response.dto.ts
-import { CreateUserSettingDto } from './create-user-setting.dto';
-import { UserSetting } from '../user-setting.entity';
-
-export class UserSettingResponseDto extends CreateUserSettingDto {
-  id: string;
-  userId: string;
-  createdAt: Date;
-  updatedAt: Date;
-
-  constructor(partial: Partial<UserSetting>) {
-    super(); // Call the constructor of CreateUserSettingDto to apply defaults
-    Object.assign(this, partial);
-  }
-}

+ 0 - 115
server/src/user-setting/user-setting.controller.ts

@@ -1,115 +0,0 @@
-// server/src/user-setting/user-setting.controller.ts
-import {
-  Body,
-  Controller,
-  Get,
-  HttpCode,
-  HttpStatus,
-  Put,
-  Req,
-  UseGuards,
-} from '@nestjs/common';
-import { UserSettingService } from './user-setting.service';
-import { UpdateUserSettingDto } from './dto/update-user-setting.dto';
-import { CombinedAuthGuard } from '../auth/combined-auth.guard';
-import { UserSettingResponseDto } from './dto/user-setting-response.dto';
-import { ModelConfigService } from '../model-config/model-config.service';
-import { plainToClass } from 'class-transformer';
-import { RolesGuard } from '../auth/roles.guard';
-import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user-role.enum';
-import { TenantService } from '../tenant/tenant.service';
-
-@UseGuards(CombinedAuthGuard, RolesGuard)
-@Controller('settings') // Global prefix /api/settings
-export class UserSettingController {
-  constructor(
-    private readonly userSettingService: UserSettingService,
-    private readonly modelConfigService: ModelConfigService,
-    private readonly tenantService: TenantService,
-  ) { }
-
-  @Get('global')
-  async getGlobal(): Promise<UserSettingResponseDto> {
-    const globalSetting = await this.userSettingService.getGlobalSettings();
-    return plainToClass(UserSettingResponseDto, globalSetting);
-  }
-
-  @Get('tenant')
-  async getTenantSettings(@Req() req) {
-    if (!req.user.tenantId) {
-      return this.userSettingService.getGlobalSettings();
-    }
-    return this.tenantService.getSettings(req.user.tenantId);
-  }
-
-  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-  @Put('global')
-  @HttpCode(HttpStatus.OK)
-  async updateGlobal(
-    @Body() updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSettingResponseDto> {
-    const globalSetting = await this.userSettingService.updateGlobalSettings(
-      updateUserSettingDto,
-    );
-    return plainToClass(UserSettingResponseDto, globalSetting);
-  }
-
-  @Get()
-  async findOne(@Req() req): Promise<UserSettingResponseDto> {
-    const userSetting = await this.userSettingService.findOrCreate(req.user.id);
-    return plainToClass(UserSettingResponseDto, userSetting);
-  }
-
-  @Put()
-  @HttpCode(HttpStatus.OK)
-  async update(
-    @Req() req,
-    @Body() updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSettingResponseDto> {
-    const userSetting = await this.userSettingService.update(
-      req.user.id,
-      updateUserSettingDto,
-    );
-    return plainToClass(UserSettingResponseDto, userSetting);
-  }
-
-  @Get('vision-models')
-  async getVisionModels(@Req() req: any) {
-    const userId = req.user.id;
-    const models = await this.modelConfigService.findByType(userId, req.user.tenantId, 'vision');
-    return models;
-  }
-
-  @Get('vision-model')
-  async getVisionModel(@Req() req: any) {
-    const userId = req.user.id;
-    const visionModelId =
-      await this.userSettingService.getVisionModelId(userId);
-    return { visionModelId };
-  }
-
-  @Put('vision-model')
-  async updateVisionModel(
-    @Req() req: any,
-    @Body() body: { visionModelId: string },
-  ) {
-    const userId = req.user.id;
-    await this.userSettingService.updateVisionModel(userId, body.visionModelId);
-    return { success: true };
-  }
-
-  @Get('language')
-  async getLanguage(@Req() req: any) {
-    const userId = req.user.id;
-    const language = await this.userSettingService.getLanguage(userId);
-    return { language };
-  }
-
-  @Put('language')
-  async updateLanguage(@Req() req: any, @Body() body: { language: string }) {
-    const userId = req.user.id;
-    await this.userSettingService.updateLanguage(userId, body.language);
-    return { success: true };
-  }
-}

+ 0 - 87
server/src/user-setting/user-setting.entity.ts

@@ -1,87 +0,0 @@
-// server/src/user-setting/user-setting.entity.ts
-import {
-  Column,
-  CreateDateColumn,
-  Entity,
-  JoinColumn,
-  OneToOne,
-  PrimaryGeneratedColumn,
-  UpdateDateColumn,
-} from 'typeorm';
-import { User } from '../user/user.entity'; // Userエンティティのパス
-
-@Entity('user_settings')
-export class UserSetting {
-  @PrimaryGeneratedColumn('uuid')
-  id: string;
-
-  @Column({ type: 'text', unique: true, nullable: true }) // Ensure one-to-one relationship via unique userId, but allow null for global
-  userId: string;
-
-  @OneToOne(() => User, (user) => user.userSetting, { onDelete: 'CASCADE', nullable: true })
-  @JoinColumn({ name: 'userId' })
-  user: User;
-
-  @Column({ type: 'boolean', default: false })
-  isGlobal: boolean;
-
-  @Column({ type: 'text', default: 'gpt-3.5-turbo' })
-  selectedLLMId: string;
-
-  @Column({ type: 'text', default: 'text-embedding-3-small' })
-  selectedEmbeddingId: string;
-
-  @Column({ type: 'text', nullable: true })
-  selectedRerankId: string;
-
-  @Column({ type: 'real', default: 0.7 })
-  temperature: number;
-
-  @Column({ type: 'integer', default: 2048 })
-  maxTokens: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableRerank: boolean;
-
-  @Column({ type: 'integer', default: 5 })
-  topK: number;
-
-  @Column({ type: 'real', default: 0.3 })
-  similarityThreshold: number;
-
-  @Column({ type: 'real', default: 0.5 })
-  rerankSimilarityThreshold: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableFullTextSearch: boolean;
-
-  @Column({ type: 'real', default: 0.7 })
-  hybridVectorWeight: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableQueryExpansion: boolean;
-
-  @Column({ type: 'boolean', default: false })
-  enableHyDE: boolean;
-
-  @Column({ type: 'integer', default: 1000 })
-  chunkSize: number;
-
-  @Column({ type: 'integer', default: 100 })
-  chunkOverlap: number;
-
-  @Column({ type: 'text', nullable: true })
-  defaultVisionModelId: string;
-
-  @Column({ type: 'text', default: 'zh' })
-  language: string;
-
-  @Column({ type: 'text', nullable: true })
-  coachKbId: string;
-
-  @CreateDateColumn({ name: 'created_at' })
-  createdAt: Date;
-
-  @UpdateDateColumn({ name: 'updated_at' })
-  updatedAt: Date;
-}

+ 0 - 16
server/src/user-setting/user-setting.module.ts

@@ -1,16 +0,0 @@
-// server/src/user-setting/user-setting.module.ts
-import { Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { UserSetting } from './user-setting.entity';
-import { UserSettingService } from './user-setting.service';
-import { UserSettingController } from './user-setting.controller';
-import { ModelConfigModule } from '../model-config/model-config.module';
-import { TenantModule } from '../tenant/tenant.module';
-
-@Module({
-  imports: [TypeOrmModule.forFeature([UserSetting]), ModelConfigModule, TenantModule],
-  providers: [UserSettingService],
-  controllers: [UserSettingController],
-  exports: [UserSettingService], // Export if other modules need to use UserSettingService
-})
-export class UserSettingModule { }

+ 0 - 169
server/src/user-setting/user-setting.service.ts

@@ -1,169 +0,0 @@
-// server/src/user-setting/user-setting.service.ts
-import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; // Added OnModuleInit, Logger
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { UserSetting } from './user-setting.entity';
-import { UpdateUserSettingDto } from './dto/update-user-setting.dto'; // Removed CreateUserSettingDto
-import { DEFAULT_SETTINGS } from '../defaults'; // Corrected import path
-
-@Injectable()
-export class UserSettingService implements OnModuleInit {
-  private readonly logger = new Logger(UserSettingService.name);
-
-  constructor(
-    @InjectRepository(UserSetting)
-    private userSettingRepository: Repository<UserSetting>,
-  ) { }
-
-  async onModuleInit() {
-    await this.initializeGlobalSettings();
-  }
-
-  private async initializeGlobalSettings() {
-    // 1. 既存のグローバル設定を検索する
-    let globalSetting = await this.userSettingRepository.findOne({
-      where: { isGlobal: true },
-    });
-
-    if (globalSetting) {
-      this.logger.log('Global settings already initialized.');
-      return;
-    }
-
-    // 2. グローバル設定がない場合、旧 'system' ユーザーから移行を試みる
-    const legacySystemSetting = await this.userSettingRepository.findOne({
-      where: { userId: 'system' },
-    });
-
-    if (legacySystemSetting) {
-      this.logger.log('Migrating legacy system settings to new global format...');
-      legacySystemSetting.isGlobal = true;
-      legacySystemSetting.userId = null as any; // Clear the old system userId
-      await this.userSettingRepository.save(legacySystemSetting);
-      this.logger.log('Migration complete.');
-    } else {
-      // 3. 旧記録もない場合は、新規作成する
-      this.logger.log('No global settings found. Creating initial global settings...');
-      const newGlobalSetting = this.userSettingRepository.create({
-        isGlobal: true,
-        userId: null as any,
-        ...DEFAULT_SETTINGS,
-      });
-      await this.userSettingRepository.save(newGlobalSetting);
-      this.logger.log('Initial global settings created.');
-    }
-  }
-
-  async findOrCreate(userId: string): Promise<UserSetting> {
-    let userSetting = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-
-    if (!userSetting) {
-      const newSetting = this.userSettingRepository.create({
-        userId,
-        ...DEFAULT_SETTINGS, // Use default frontend settings as initial backend settings
-      });
-      userSetting = await this.userSettingRepository.save(newSetting);
-    }
-    return userSetting;
-  }
-
-  async update(
-    userId: string,
-    updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSetting> {
-    const existingSetting = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-
-    if (!existingSetting) {
-      // If no setting exists, create one with default values and then apply updates
-      const newSetting = this.userSettingRepository.create({
-        userId,
-        ...DEFAULT_SETTINGS,
-        ...updateUserSettingDto,
-      });
-      return this.userSettingRepository.save(newSetting);
-    }
-
-    const updated = this.userSettingRepository.merge(
-      existingSetting,
-      updateUserSettingDto,
-    );
-    return this.userSettingRepository.save(updated);
-  }
-
-  async updateVisionModel(
-    userId: string,
-    visionModelId: string,
-  ): Promise<UserSetting> {
-    const settings = await this.findOrCreate(userId);
-    settings.defaultVisionModelId = visionModelId;
-    return this.userSettingRepository.save(settings);
-  }
-
-  async getVisionModelId(userId: string): Promise<string | null> {
-    const settings = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-    return settings?.defaultVisionModelId || null;
-  }
-
-  async updateLanguage(userId: string, language: string): Promise<UserSetting> {
-    console.log('=== updateLanguage デバッグ ===');
-    console.log('userId:', userId);
-    console.log('新しい言語:', language);
-    const settings = await this.findOrCreate(userId);
-    console.log('更新前 settings:', settings);
-    settings.language = language;
-    const result = await this.userSettingRepository.save(settings);
-    console.log('更新後 result:', result);
-    console.log('===============================');
-    return result;
-  }
-
-  async getLanguage(userId: string): Promise<string> {
-    const settings = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-    console.log('=== getLanguage デバッグ ===');
-    console.log('userId:', userId);
-    console.log('settings:', settings);
-    console.log('settings.language:', settings?.language);
-    console.log('返す言語:', settings?.language || 'zh');
-    console.log('============================');
-    return settings?.language || 'zh';
-  }
-
-  /**
-   * システム全体のグローバル設定を取得する
-   */
-  async getGlobalSettings(): Promise<UserSetting> {
-    const globalSetting = await this.userSettingRepository.findOne({
-      where: { isGlobal: true },
-    });
-
-    if (!globalSetting) {
-      // 万が一存在しない場合は初期化
-      await this.initializeGlobalSettings();
-      return this.getGlobalSettings();
-    }
-    return globalSetting;
-  }
-
-  /**
-   * システム全体のグローバル設定を更新する
-   */
-  async updateGlobalSettings(
-    updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSetting> {
-    const globalSetting = await this.getGlobalSettings();
-    const updated = this.userSettingRepository.merge(
-      globalSetting,
-      updateUserSettingDto,
-    );
-    return this.userSettingRepository.save(updated);
-  }
-}
-

+ 7 - 3
server/src/user/dto/create-user.dto.ts

@@ -1,4 +1,6 @@
-import { IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator';
+import { IsNotEmpty, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { UserRole } from '../user-role.enum';
 
 export class CreateUserDto {
   @IsString()
@@ -10,7 +12,9 @@ export class CreateUserDto {
   @MinLength(8, { message: 'Password must be at least 8 characters long' })
   password: string;
 
-  @IsOptional()
+  @ApiPropertyOptional()
   @IsString()
-  role?: string;
+  @IsOptional()
+  displayName?: string;
+
 }

+ 7 - 4
server/src/user/dto/update-user.dto.ts

@@ -1,6 +1,12 @@
-import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
+import { IsBoolean, IsOptional, IsString, MinLength, IsEnum } from 'class-validator';
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { UserRole } from '../user-role.enum';
 
 export class UpdateUserDto {
+  @IsOptional()
+  @IsString()
+  displayName?: string;
+
   @IsOptional()
   @IsBoolean()
   isAdmin?: boolean;
@@ -9,9 +15,6 @@ export class UpdateUserDto {
   @IsString()
   tenantId?: string;
 
-  @IsOptional()
-  @IsString()
-  role?: string;
 
   @IsOptional()
   @IsString()

+ 2 - 1
server/src/user/dto/user-safe.dto.ts

@@ -5,8 +5,9 @@ import { UserRole } from '../user-role.enum';
 export type SafeUser = {
   id: string;
   username: string;
+  displayName?: string;
   isAdmin: boolean;
-  role: UserRole;
+  role: UserRole; // Computed property
   tenantId: string;
   createdAt: Date;
   updatedAt: Date;

+ 32 - 0
server/src/user/user-setting.entity.ts

@@ -0,0 +1,32 @@
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinColumn,
+  OneToOne,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+} from 'typeorm';
+import { User } from './user.entity';
+
+@Entity('user_settings')
+export class UserSetting {
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
+
+  @Column({ type: 'text' })
+  userId: string;
+
+  @OneToOne(() => User, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'userId' })
+  user: User;
+
+  @Column({ type: 'text', default: 'zh' })
+  language: string;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
+}

+ 27 - 0
server/src/user/user-setting.service.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { UserSetting } from './user-setting.entity';
+
+@Injectable()
+export class UserSettingService {
+  constructor(
+    @InjectRepository(UserSetting)
+    private userSettingRepository: Repository<UserSetting>,
+  ) {}
+
+  async getByUser(userId: string): Promise<UserSetting> {
+    let setting = await this.userSettingRepository.findOne({ where: { userId } });
+    if (!setting) {
+      setting = this.userSettingRepository.create({ userId, language: 'zh' });
+      await this.userSettingRepository.save(setting);
+    }
+    return setting;
+  }
+
+  async update(userId: string, language: string): Promise<UserSetting> {
+    const setting = await this.getByUser(userId);
+    setting.language = language;
+    return this.userSettingRepository.save(setting);
+  }
+}

+ 46 - 34
server/src/user/user.controller.ts

@@ -11,11 +11,15 @@ import {
   Put,
   Request,
   UseGuards,
+  Query,
 } from '@nestjs/common';
 import { UserService } from './user.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { CreateUserDto } from './dto/create-user.dto';
 import { UpdateUserDto } from './dto/update-user.dto';
 import { I18nService } from '../i18n/i18n.service';
+import { UserRole } from './user-role.enum';
+import { UserSettingService } from './user-setting.service';
 
 @Controller('users')
 @UseGuards(CombinedAuthGuard)
@@ -23,6 +27,7 @@ export class UserController {
   constructor(
     private readonly userService: UserService,
     private readonly i18nService: I18nService,
+    private readonly userSettingService: UserSettingService,
   ) { }
 
   // --- API Key Management ---
@@ -38,6 +43,18 @@ export class UserController {
     return { apiKey };
   }
 
+  // --- Personal Settings ---
+  @Get('settings')
+  async getSettings(@Request() req) {
+    return this.userSettingService.getByUser(req.user.id);
+  }
+
+  @Put('settings/language')
+  async updateLanguage(@Request() req, @Body() body: { language: string }) {
+    if (!body.language) throw new BadRequestException('language is required');
+    return this.userSettingService.update(req.user.id, body.language);
+  }
+
   // --- Profile ---
   @Get('profile')
   async getProfile(@Request() req: any) {
@@ -65,7 +82,8 @@ export class UserController {
     return {
       id: user.id,
       username: user.username,
-      role: user.role,
+      displayName: user.displayName,
+      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
       tenantId: user.tenantId,
       tenantName,
       isAdmin: user.isAdmin,
@@ -74,16 +92,23 @@ export class UserController {
   }
 
   @Get()
-  async findAll(@Request() req) {
+  async findAll(
+    @Request() req,
+    @Query('page') page?: string,
+    @Query('limit') limit?: string,
+  ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyViewList'));
     }
 
-    if (callerRole === 'SUPER_ADMIN') {
-      return this.userService.findAll();
+    const p = page ? parseInt(page) : undefined;
+    const l = limit ? parseInt(limit) : undefined;
+
+    if (callerRole === UserRole.SUPER_ADMIN) {
+      return this.userService.findAll(p, l);
     } else {
-      return this.userService.findByTenantId(req.user.tenantId);
+      return this.userService.findByTenantId(req.user.tenantId, p, l);
     }
   }
 
@@ -112,10 +137,10 @@ export class UserController {
   @Post()
   async createUser(
     @Request() req,
-    @Body() body: { username: string; password: string; role?: string },
+    @Body() body: CreateUserDto,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyCreateUser'));
     }
 
@@ -129,22 +154,18 @@ export class UserController {
       throw new BadRequestException(this.i18nService.getErrorMessage('passwordMinLength'));
     }
 
-    // Determine target role based on caller's role and requested role
-    let targetRole = 'USER';
+    // All new global users default to non-admin. 
+    // Elevation to Super Admin status is handled separately.
     let isAdmin = false;
 
-    if (callerRole === 'SUPER_ADMIN') {
-      // Super Admin can create TENANT_ADMIN or USER. Default to requested role, fallback to USER.
-      targetRole = body.role === 'TENANT_ADMIN' ? 'TENANT_ADMIN' : 'USER';
-      isAdmin = targetRole === 'TENANT_ADMIN';
-    } else if (callerRole === 'TENANT_ADMIN') {
-      // Tenant Admin can ONLY create regular users.
-      targetRole = 'USER';
+    if (callerRole === UserRole.SUPER_ADMIN) {
+      isAdmin = false;
+    } else if (callerRole === UserRole.TENANT_ADMIN) {
       isAdmin = false;
     }
 
     // Pass the calculated params to the service
-    return this.userService.createUser(username, password, isAdmin, req.user.tenantId, targetRole as any);
+    return this.userService.createUser(username, password, isAdmin, req.user.tenantId, body.displayName);
   }
 
   @Put(':id')
@@ -154,7 +175,7 @@ export class UserController {
     @Param('id') id: string,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyUpdateUser'));
     }
 
@@ -173,20 +194,11 @@ export class UserController {
       throw new ForbiddenException(this.i18nService.getErrorMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // Role modification logic
-    if (body.role && userToUpdate.role !== body.role) {
-      if (callerRole !== 'SUPER_ADMIN') {
-        throw new ForbiddenException('Only Super Admins can change user roles.');
-      }
-      if (userToUpdate.role === 'SUPER_ADMIN') {
-        throw new ForbiddenException('Cannot modify the role of another Super Admin.');
-      }
-
-      // Sync isAdmin based on the newly selected role
-      if (body.role === 'TENANT_ADMIN' || body.role === 'SUPER_ADMIN') {
-        body.isAdmin = true;
-      } else {
-        body.isAdmin = false;
+    // Role modification is now obsolete on global level. 
+    // If Admin wants to elevate, they set isAdmin property directly.
+    if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
+      if (callerRole !== UserRole.SUPER_ADMIN) {
+        throw new ForbiddenException('Only Super Admins can change user admin status.');
       }
     }
 
@@ -199,7 +211,7 @@ export class UserController {
     @Param('id') id: string,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyDeleteUser'));
     }
 

+ 5 - 9
server/src/user/user.entity.ts

@@ -12,12 +12,12 @@ import {
 } from 'typeorm';
 import * as bcrypt from 'bcrypt';
 import { ModelConfig } from '../model-config/model-config.entity';
-import { UserSetting } from '../user-setting/user-setting.entity';
 import { Tenant } from '../tenant/tenant.entity';
 import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 
 import { UserRole } from './user-role.enum';
+import { UserSetting } from './user-setting.entity';
 
 @Entity('users')
 export class User {
@@ -34,13 +34,9 @@ export class User {
   @Column({ type: 'boolean', default: false })
   isAdmin: boolean;
 
-  // New role-based access control
-  @Column({
-    type: 'simple-enum',
-    enum: UserRole,
-    default: UserRole.USER,
-  })
-  role: UserRole;
+  @Column({ type: 'text', nullable: false })
+  displayName: string;
+
 
   // Multi-tenancy: A user can belong to multiple tenants via TenantMember
   @OneToMany(() => TenantMember, (member) => member.user)
@@ -73,7 +69,7 @@ export class User {
   @OneToMany(() => ModelConfig, (modelConfig) => modelConfig.user)
   modelConfigs: ModelConfig[];
 
-  @OneToOne(() => UserSetting, (userSetting) => userSetting.user)
+  @OneToOne(() => UserSetting, (setting) => setting.user)
   userSetting: UserSetting;
 
   @BeforeInsert()

+ 5 - 3
server/src/user/user.module.ts

@@ -1,6 +1,8 @@
 import { Module, Global } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { User } from './user.entity';
+import { UserSetting } from './user-setting.entity';
+import { UserSettingService } from './user-setting.service';
 import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 import { UserService } from './user.service';
@@ -10,11 +12,11 @@ import { TenantModule } from '../tenant/tenant.module';
 @Global()
 @Module({
   imports: [
-    TypeOrmModule.forFeature([User, ApiKey, TenantMember]),
+    TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
     TenantModule,
   ],
   controllers: [UserController],
-  providers: [UserService],
-  exports: [UserService],
+  providers: [UserService, UserSettingService],
+  exports: [UserService, UserSettingService],
 })
 export class UserModule { }

+ 46 - 34
server/src/user/user.service.ts

@@ -41,12 +41,41 @@ export class UserService implements OnModuleInit {
 
 
 
-  async findAll(): Promise<User[]> {
-    return this.usersRepository.find({
-      select: ['id', 'username', 'isAdmin', 'role', 'createdAt', 'tenantId'],
-      relations: ['tenantMembers', 'tenantMembers.tenant'],
-      order: { createdAt: 'DESC' },
-    });
+  async findAll(page?: number, limit?: number): Promise<{ data: User[]; total: number }> {
+    const queryBuilder = this.usersRepository.createQueryBuilder('user')
+      .leftJoinAndSelect('user.tenantMembers', 'tenantMember')
+      .leftJoinAndSelect('tenantMember.tenant', 'tenant')
+      .select(['user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId', 'tenantMember', 'tenant'])
+      .orderBy('user.createdAt', 'DESC');
+
+    if (page && limit) {
+      const [data, total] = await queryBuilder
+        .skip((page - 1) * limit)
+        .take(limit)
+        .getManyAndCount();
+      return { data, total };
+    }
+
+    const [data, total] = await queryBuilder.getManyAndCount();
+    return { data, total };
+  }
+
+  async findByTenantId(tenantId: string, page?: number, limit?: number): Promise<{ data: User[]; total: number }> {
+    const queryBuilder = this.usersRepository.createQueryBuilder('user')
+      .innerJoin('user.tenantMembers', 'member', 'member.tenantId = :tenantId', { tenantId })
+      .select(['user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId'])
+      .orderBy('user.createdAt', 'DESC');
+
+    if (page && limit) {
+      const [data, total] = await queryBuilder
+        .skip((page - 1) * limit)
+        .take(limit)
+        .getManyAndCount();
+      return { data, total };
+    }
+
+    const [data, total] = await queryBuilder.getManyAndCount();
+    return { data, total };
   }
 
   async isAdmin(userId: string): Promise<boolean> {
@@ -86,27 +115,26 @@ export class UserService implements OnModuleInit {
     password: string,
     isAdmin: boolean = false,
     tenantId?: string,
-    role?: UserRole,
-  ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
+    displayName?: string,
+  ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean } }> {
     const existingUser = await this.findOneByUsername(username);
     if (existingUser) {
       throw new ConflictException(this.i18nService.getMessage('usernameExists'));
     }
 
     const hashedPassword = await bcrypt.hash(password, 10);
-    const targetRoleValue = role ?? (isAdmin ? UserRole.TENANT_ADMIN : UserRole.USER);
-    console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}, role: ${targetRoleValue}`);
+    console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`);
     const user = await this.usersRepository.save({
       username,
       password: hashedPassword,
+      displayName,
       isAdmin,
       tenantId: tenantId ?? undefined,
-      role: targetRoleValue,
     } as any);
 
     return {
       message: this.i18nService.getMessage('userCreated'),
-      user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
+      user: { id: user.id, username: user.username, displayName: user.displayName, isAdmin: user.isAdmin },
     };
   }
 
@@ -125,18 +153,10 @@ export class UserService implements OnModuleInit {
     return apiKey ? apiKey.user : null;
   }
 
-  async findByTenantId(tenantId: string): Promise<User[]> {
-    const members = await this.tenantMemberRepository.find({
-      where: { tenantId },
-      relations: ['user']
-    });
-    return members.map(m => m.user);
-  }
-
   async getUserTenants(userId: string): Promise<(TenantMember & { features?: { isNotebookEnabled: boolean } })[]> {
-    const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['role'] });
+    const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['isAdmin'] });
 
-    if (user?.role === UserRole.SUPER_ADMIN) {
+    if (user?.isAdmin) {
       const allTenants = await this.tenantService.findAll();
       const results = await Promise.all(allTenants.map(async t => {
         const settings = await this.tenantService.getSettings(t.id);
@@ -222,8 +242,8 @@ export class UserService implements OnModuleInit {
 
   async updateUser(
     userId: string,
-    updateData: { isAdmin?: boolean; role?: string; password?: string; tenantId?: string },
-  ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
+    updateData: { username?: string; isAdmin?: boolean; password?: string; tenantId?: string; displayName?: string },
+  ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean } }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
       throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
@@ -240,20 +260,12 @@ export class UserService implements OnModuleInit {
       throw new ForbiddenException(this.i18nService.getMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // Apply role update logic
-    if (updateData.role) {
-      if (updateData.role === UserRole.TENANT_ADMIN || updateData.role === UserRole.SUPER_ADMIN) {
-        updateData.isAdmin = true;
-      } else {
-        updateData.isAdmin = false;
-      }
-    }
 
     await this.usersRepository.update(userId, updateData as any);
 
     const updatedUser = await this.usersRepository.findOne({
       where: { id: userId },
-      select: ['id', 'username', 'isAdmin'],
+      select: ['id', 'username', 'displayName', 'isAdmin'],
     });
 
     return {
@@ -261,6 +273,7 @@ export class UserService implements OnModuleInit {
       user: {
         id: updatedUser!.id,
         username: updatedUser!.username,
+        displayName: updatedUser!.displayName,
         isAdmin: updatedUser!.isAdmin
       },
     };
@@ -298,7 +311,6 @@ export class UserService implements OnModuleInit {
         username: 'admin',
         password: hashedPassword,
         isAdmin: true,
-        role: UserRole.SUPER_ADMIN,
       });
 
       console.log('\n=== 管理者アカウントが作成されました ===');

+ 7 - 2
web/components/SettingsModal.tsx

@@ -97,14 +97,19 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         if (!authToken) return;
         setIsSettingsLoading(true);
         try {
-            const [settings, groups, users] = await Promise.all([
+            const [settings, groups, users, personal] = await Promise.all([
                 userSettingService.get(authToken),
                 knowledgeGroupService.getGroups(),
-                userService.getUsers().catch(() => []) // Regular users might fail this
+                userService.getUsers().catch(() => []), // Regular users might fail this
+                userSettingService.getPersonal(authToken).catch(() => null)
             ]);
             setAppSettings(settings);
             setKnowledgeGroups(groups);
 
+            if (personal?.language && personal.language !== language) {
+                setLanguage(personal.language as any);
+            }
+
             // Temporary way to get current user details since we lack a /me endpoint hook here
             const tokenPayload = JSON.parse(atob(authToken.split('.')[1]));
             const me = users.find(u => u.id === tokenPayload.sub) || { isAdmin: tokenPayload.role === 'SUPER_ADMIN' || tokenPayload.role === 'TENANT_ADMIN', role: tokenPayload.role };

+ 7 - 19
web/components/views/ChatView.tsx

@@ -94,28 +94,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
     const fetchAndSetSettings = useCallback(async () => {
         if (!authToken) return
         try {
-            const [userSettings, globalSettings, tenantSettings] = await Promise.all([
-                userSettingService.get(authToken),
-                userSettingService.getGlobal(authToken),
-                userSettingService.getTenant(authToken).catch(() => ({} as Partial<AppSettings>))
+            const [personalSettings, tenantSettings] = await Promise.all([
+                userSettingService.getPersonal(authToken).catch(() => null),
+                userSettingService.get(authToken).catch(() => ({} as Partial<AppSettings>))
             ]);
 
             const appSettings: AppSettings = {
-                language: userSettings.language || tenantSettings.language || globalSettings.language || DEFAULT_SETTINGS.language,
-                selectedLLMId: userSettings.selectedLLMId || tenantSettings.selectedLLMId || globalSettings.selectedLLMId || DEFAULT_SETTINGS.selectedLLMId,
-                selectedEmbeddingId: userSettings.selectedEmbeddingId || tenantSettings.selectedEmbeddingId || globalSettings.selectedEmbeddingId || DEFAULT_SETTINGS.selectedEmbeddingId,
-                selectedRerankId: userSettings.selectedRerankId || tenantSettings.selectedRerankId || globalSettings.selectedRerankId || '',
-                temperature: userSettings.temperature ?? tenantSettings.temperature ?? globalSettings.temperature ?? DEFAULT_SETTINGS.temperature,
-                maxTokens: userSettings.maxTokens ?? tenantSettings.maxTokens ?? globalSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
-                enableRerank: userSettings.enableRerank ?? tenantSettings.enableRerank ?? globalSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
-                topK: userSettings.topK ?? tenantSettings.topK ?? globalSettings.topK ?? DEFAULT_SETTINGS.topK,
-                similarityThreshold: userSettings.similarityThreshold ?? tenantSettings.similarityThreshold ?? globalSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
-                rerankSimilarityThreshold: userSettings.rerankSimilarityThreshold ?? tenantSettings.rerankSimilarityThreshold ?? globalSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
-                enableFullTextSearch: userSettings.enableFullTextSearch ?? tenantSettings.enableFullTextSearch ?? globalSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
-                hybridVectorWeight: userSettings.hybridVectorWeight ?? tenantSettings.hybridVectorWeight ?? globalSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight,
-                enableQueryExpansion: userSettings.enableQueryExpansion ?? tenantSettings.enableQueryExpansion ?? globalSettings.enableQueryExpansion ?? DEFAULT_SETTINGS.enableQueryExpansion,
-                enableHyDE: userSettings.enableHyDE ?? tenantSettings.enableHyDE ?? globalSettings.enableHyDE ?? DEFAULT_SETTINGS.enableHyDE
-            }
+                ...DEFAULT_SETTINGS,
+                ...tenantSettings,
+                language: personalSettings?.language || tenantSettings?.language || DEFAULT_SETTINGS.language,
+            };
             setSettings(appSettings)
         } catch (error) {
             console.error('Failed to fetch settings:', error)

+ 1 - 3
web/components/views/KnowledgeBaseView.tsx

@@ -261,9 +261,7 @@ export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
     const fetchAndSetSettings = useCallback(async () => {
         if (!authToken) return
         try {
-            const settingsData = isAdmin
-                ? await userSettingService.getGlobal(authToken)
-                : await userSettingService.get(authToken);
+            const settingsData = await userSettingService.get(authToken);
             setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
         } catch (error) {
             console.error('Failed to fetch settings:', error)

文件差異過大導致無法顯示
+ 791 - 797
web/components/views/SettingsView.tsx


+ 16 - 0
web/services/apiClient.ts

@@ -76,6 +76,22 @@ class ApiClient {
     return { data, status: response.status };
   }
 
+  async patch<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
+    const response = await fetch(`${this.baseURL}${url}`, {
+      method: 'PATCH',
+      headers: this.getAuthHeaders(),
+      body: body ? JSON.stringify(body) : undefined,
+    });
+
+    if (!response.ok) {
+      const errorData = await response.json();
+      throw new Error(errorData.message || 'Request failed');
+    }
+
+    const data = await response.json();
+    return { data, status: response.status };
+  }
+
   async delete<T = any>(url: string): Promise<ApiResponse<T>> {
     const response = await fetch(`${this.baseURL}${url}`, {
       method: 'DELETE',

+ 21 - 23
web/services/chatService.ts

@@ -1,3 +1,5 @@
+import { apiClient } from './apiClient';
+
 export interface ChatMessage {
   role: 'user' | 'assistant';
   content: string;
@@ -34,12 +36,10 @@ export class ChatService {
     enableHyDE?: boolean // 追加
   ): AsyncGenerator<{ type: 'content' | 'sources' | 'error' | 'historyId'; data: any }> {
     try {
-      const response = await fetch('/api/chat/stream', {
+      const response = await apiClient.request('/chat/stream', {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
-          'Authorization': `Bearer ${authToken}`,
-          'x-api-key': localStorage.getItem('kb_api_key') || authToken,
           'x-user-language': userLanguage || localStorage.getItem('userLanguage') || 'ja',
         },
         body: JSON.stringify({
@@ -47,29 +47,29 @@ export class ChatService {
           history,
           userLanguage,
           selectedEmbeddingId,
-          selectedLLMId, // Pass LLM ID
-          selectedGroups, // グループフィルタパラメータを渡す
-          selectedFiles, // ファイルフィルタパラメータを渡す
-          historyId, // 履歴 ID を渡す
+          selectedLLMId,
+          selectedGroups,
+          selectedFiles,
+          historyId,
           enableRerank,
           selectedRerankId,
-          temperature, // temperature パラメータを渡す
-          maxTokens, // maxTokens パラメータを渡す
-          topK, // topK パラメータを渡す
-          similarityThreshold, // similarityThreshold パラメータを渡す
-          rerankSimilarityThreshold, // rerankSimilarityThreshold パラメータを渡す
-          enableQueryExpansion, // enableQueryExpansion を渡す
-          enableHyDE // enableHyDE を渡す
+          temperature,
+          maxTokens,
+          topK,
+          similarityThreshold,
+          rerankSimilarityThreshold,
+          enableQueryExpansion,
+          enableHyDE
         }),
       });
 
       if (!response.ok) {
-        let errorMessage = 'リクエストに失敗しました';
+        let errorMessage = 'Request failed';
         try {
           const error = await response.json();
-          errorMessage = error.error || error.message || 'リクエストに失敗しました';
+          errorMessage = error.error || error.message || 'Request failed';
         } catch {
-          errorMessage = `サーバーエラー: ${response.status}`;
+          errorMessage = `Server error: ${response.status}`;
         }
         yield { type: 'error', data: errorMessage };
         return;
@@ -108,8 +108,8 @@ export class ChatService {
           }
         }
       }
-    } catch (error) {
-      yield { type: 'error', data: error.message || 'ネットワークエラー' };
+    } catch (error: any) {
+      yield { type: 'error', data: error.message || 'Network error' };
     }
   }
 
@@ -119,19 +119,17 @@ export class ChatService {
     authToken: string
   ): AsyncGenerator<{ type: 'content' | 'error'; data: any }> {
     try {
-      const response = await fetch('/api/chat/assist', {
+      const response = await apiClient.request('/chat/assist', {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
-          'Authorization': `Bearer ${authToken}`,
-          'x-api-key': localStorage.getItem('kb_api_key') || authToken,
           'x-user-language': localStorage.getItem('userLanguage') || 'ja',
         },
         body: JSON.stringify({ instruction, context }),
       });
 
       if (!response.ok) {
-        yield { type: 'error', data: 'リクエストに失敗しました' };
+        yield { type: 'error', data: 'Request failed' };
         return;
       }
 

+ 11 - 14
web/services/settingsService.ts

@@ -2,35 +2,32 @@ import { apiClient } from './apiClient';
 
 export const settingsService = {
   async getVisionModels() {
-    const response = await apiClient.get('/settings/vision-models');
-    return response.data;
+    const response = await apiClient.get('/models');
+    // Filter models that support vision or are of type vision
+    return response.data.filter((m: any) => m.supportsVision || m.type === 'vision');
   },
 
   async getVisionModel() {
-    const response = await apiClient.get('/settings/vision-model');
-    return response.data;
+    const response = await apiClient.get('/v1/admin/settings');
+    return { visionModelId: response.data.selectedVisionId };
   },
 
-  async updateVisionModel(visionModelId: string) {
-    const response = await apiClient.put('/settings/vision-model', {
-      visionModelId,
+  async updateVisionModel(selectedVisionId: string) {
+    const response = await apiClient.put('/v1/admin/settings', {
+      selectedVisionId,
     });
     return response.data;
   },
 
   async getLanguage() {
-    const response = await apiClient.get('/settings/language');
-    return response.data;
+    const response = await apiClient.get('/users/settings');
+    return response.data.language;
   },
 
   async updateLanguage(language: string) {
-    console.log('=== API 调用 updateLanguage ===');
-    console.log('请求语言:', language);
-    console.log('请求URL:', '/settings/language');
-    const response = await apiClient.put('/settings/language', {
+    const response = await apiClient.put('/users/settings/language', {
       language,
     });
-    console.log('API 响应:', response);
     return response.data;
   },
 };

+ 10 - 5
web/services/userService.ts

@@ -9,8 +9,11 @@ export const userService = {
     return data;
   },
 
-  async getUsers(): Promise<any[]> {
-    const { data } = await apiClient.get('/users');
+  async getUsers(page?: number, limit?: number): Promise<any> {
+    const params = new URLSearchParams();
+    if (page) params.append('page', page.toString());
+    if (limit) params.append('limit', limit.toString());
+    const { data } = await apiClient.get(`/users?${params.toString()}`);
     return data;
   },
 
@@ -21,7 +24,7 @@ export const userService = {
     return data;
   },
 
-  async updateUserInfo(userId: string, userData: { isAdmin?: boolean; role?: string; password?: string }): Promise<{ message: string }> {
+  async updateUserInfo(userId: string, userData: { username?: string; isAdmin?: boolean; password?: string; displayName?: string }): Promise<{ message: string }> {
     const { data } = await apiClient.put(`/users/${userId}`, userData);
     return data;
   },
@@ -31,11 +34,13 @@ export const userService = {
     return data;
   },
 
-  async createUser(username: string, password: string, role?: string): Promise<{ message: string }> {
+  async createUser(username: string, password: string, isAdmin: boolean = false, tenantId?: string, displayName?: string): Promise<{ message: string }> {
     const { data } = await apiClient.post('/users', {
       username,
       password,
-      role,
+      isAdmin,
+      tenantId,
+      displayName,
     });
     return data;
   },

+ 23 - 70
web/services/userSettingService.ts

@@ -1,8 +1,16 @@
 // web/services/userSettingService.ts
-import { API_BASE_URL } from '../utils/constants';
+import { apiClient } from './apiClient';
 import { AppSettings } from '../types'; // Frontend AppSettings interface
 
-// Assuming backend returns all AppSettings fields, plus id, userId, createdAt, updatedAt
+// Assuming backend returns language, plus id, userId, createdAt, updatedAt
+interface UserPersonalSettingResponse {
+  id: string;
+  userId: string;
+  language: string;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
 interface UserSettingResponse extends AppSettings {
   id: string;
   userId: string;
@@ -11,80 +19,25 @@ interface UserSettingResponse extends AppSettings {
 }
 
 export const userSettingService = {
-  async get(token: string): Promise<UserSettingResponse> {
-    const response = await fetch(`${API_BASE_URL}/settings`, {
-      method: 'GET',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${token}`,
-      },
-    });
-    if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Failed to fetch user settings');
-    }
-    return response.json();
+  async get(_token: string): Promise<UserSettingResponse> {
+    const { data } = await apiClient.get<UserSettingResponse>('/v1/admin/settings');
+    return data;
   },
 
-  async update(token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
-    const response = await fetch(`${API_BASE_URL}/settings`, {
-      method: 'PUT',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${token}`,
-      },
-      body: JSON.stringify(settings),
-    });
-    if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Failed to update user settings');
-    }
-    return response.json();
+  async update(_token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
+    const { data } = await apiClient.put<UserSettingResponse>('/v1/admin/settings', settings);
+    return data;
   },
 
-  async getGlobal(token: string): Promise<UserSettingResponse> {
-    const response = await fetch(`${API_BASE_URL}/settings/global`, {
-      method: 'GET',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${token}`,
-      },
-    });
-    if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Failed to fetch global settings');
-    }
-    return response.json();
+  async getPersonal(_token: string): Promise<UserPersonalSettingResponse> {
+    const { data } = await apiClient.get<UserPersonalSettingResponse>('/users/settings');
+    return data;
   },
 
-  async getTenant(token: string): Promise<Partial<UserSettingResponse>> {
-    const response = await fetch(`${API_BASE_URL}/settings/tenant`, {
-      method: 'GET',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${token}`,
-      },
-    });
-    if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Failed to fetch tenant settings');
-    }
-    return response.json();
+  async updateLanguage(_token: string, language: string): Promise<UserPersonalSettingResponse> {
+    const { data } = await apiClient.put<UserPersonalSettingResponse>('/users/settings/language', { language });
+    return data;
   },
 
-  async updateGlobal(token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
-    const response = await fetch(`${API_BASE_URL}/settings/global`, {
-      method: 'PUT',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${token}`,
-      },
-      body: JSON.stringify(settings),
-    });
-    if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Failed to update global settings');
-    }
-    return response.json();
-  },
+  // Unused legacy methods removed
 };

+ 78 - 24
web/src/components/layouts/WorkspaceLayout.tsx

@@ -24,6 +24,41 @@ import { useAuth } from '../../contexts/AuthContext';
 import { useLanguage } from '../../../contexts/LanguageContext';
 import { cn } from '../../utils/cn';
 
+interface TenantTreeNode {
+    tenantId: string;
+    role: string;
+    tenant: {
+        id: string;
+        name: string;
+        domain?: string;
+        parentId?: string;
+    };
+    children?: TenantTreeNode[];
+}
+
+const buildTenantTree = (memberships: any[]): TenantTreeNode[] => {
+    const map = new Map<string, TenantTreeNode>();
+    const roots: TenantTreeNode[] = [];
+
+    memberships.forEach(m => {
+        map.set(m.tenantId, { ...m, children: [] });
+    });
+
+    memberships.forEach(m => {
+        const node = map.get(m.tenantId)!;
+        const parentId = (m.tenant as any)?.parentId;
+        if (parentId && map.has(parentId)) {
+            const parent = map.get(parentId)!;
+            parent.children = parent.children || [];
+            parent.children.push(node);
+        } else {
+            roots.push(node);
+        }
+    });
+
+    return roots;
+};
+
 interface SidebarItemProps {
     icon: React.ElementType;
     label: string;
@@ -207,30 +242,49 @@ const WorkspaceLayout: React.FC = () => {
                                             <div className="p-3 border-b border-slate-100">
                                                 <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2">{t('navTenants')}</span>
                                             </div>
-                                            <div className="max-h-64 overflow-y-auto p-1">
-                                                {availableTenants.map(membership => (
-                                                    <button
-                                                        key={membership.tenantId}
-                                                        onClick={() => {
-                                                            switchTenant(membership.tenantId);
-                                                            setShowTenantMenu(false);
-                                                        }}
-                                                        className={cn(
-                                                            "w-full text-left px-4 py-3 rounded-xl flex items-center justify-between group transition-colors",
-                                                            activeTenant?.tenantId === membership.tenantId
-                                                                ? "bg-blue-50 text-blue-700"
-                                                                : "hover:bg-slate-50 text-slate-600"
-                                                        )}
-                                                    >
-                                                        <div className="flex flex-col">
-                                                            <span className="text-sm font-bold">{membership.tenant.name}</span>
-                                                            <span className="text-[10px] font-medium opacity-60 uppercase tracking-tight">{membership.role}</span>
-                                                        </div>
-                                                        {activeTenant?.tenantId === membership.tenantId && (
-                                                            <div className="w-1.5 h-1.5 bg-blue-600 rounded-full" />
-                                                        )}
-                                                    </button>
-                                                ))}
+                                            <div className="max-h-80 overflow-y-auto p-1">
+                                                {(() => {
+                                                    const tree = buildTenantTree(availableTenants);
+                                                    const renderTenantItem = (node: TenantTreeNode, depth: number = 0) => {
+                                                        const isSelected = activeTenant?.tenantId === node.tenantId;
+                                                        return (
+                                                            <React.Fragment key={node.tenantId}>
+                                                                <button
+                                                                    onClick={() => {
+                                                                        switchTenant(node.tenantId);
+                                                                        setShowTenantMenu(false);
+                                                                    }}
+                                                                    className={cn(
+                                                                        "w-full text-left px-4 py-2.5 rounded-xl flex items-center justify-between group transition-colors",
+                                                                        isSelected
+                                                                            ? "bg-blue-50 text-blue-700"
+                                                                            : "hover:bg-slate-50 text-slate-600"
+                                                                    )}
+                                                                    style={{ marginLeft: `${depth * 12}px`, width: `calc(100% - ${depth * 12}px)` }}
+                                                                >
+                                                                    <div className="flex items-center gap-2 min-w-0">
+                                                                        {depth > 0 && (
+                                                                            <ChevronRight size={12} className="text-slate-300 shrink-0" />
+                                                                        )}
+                                                                        <div className="flex flex-col min-w-0">
+                                                                            <span className="text-sm font-bold truncate">{node.tenant.name}</span>
+                                                                            <span className="text-[9px] font-black opacity-40 uppercase tracking-tighter leading-none">{node.role?.replace('_', ' ')}</span>
+                                                                        </div>
+                                                                    </div>
+                                                                    {isSelected && (
+                                                                        <div className="w-1.5 h-1.5 bg-blue-600 rounded-full shrink-0 ml-2" />
+                                                                    )}
+                                                                </button>
+                                                                {node.children && node.children.length > 0 && (
+                                                                    <div className="mt-0.5 mb-1">
+                                                                        {node.children.map(child => renderTenantItem(child, depth + 1))}
+                                                                    </div>
+                                                                )}
+                                                            </React.Fragment>
+                                                        );
+                                                    };
+                                                    return tree.map(root => renderTenantItem(root));
+                                                })()}
                                             </div>
                                         </motion.div>
                                     </>

+ 6 - 2
web/src/contexts/AuthContext.tsx

@@ -11,6 +11,7 @@ export interface User {
     email?: string;
     tenant_name?: string;
     isNotebookEnabled?: boolean;
+    displayName?: string;
 }
 
 export interface TenantMembership {
@@ -54,10 +55,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             });
             if (res.ok) {
                 const tenants: TenantMembership[] = await res.json();
-                setAvailableTenants(tenants);
+                const filteredTenants = tenants.filter(t => t.tenant?.name !== 'Default');
+                setAvailableTenants(filteredTenants);
 
                 const savedTenantId = localStorage.getItem('kb_active_tenant_id') || currentTenantId;
-                const active = tenants.find(t => t.tenantId === savedTenantId) || tenants[0] || null;
+                const active = filteredTenants.find(t => t.tenantId === savedTenantId) || filteredTenants[0] || null;
                 setActiveTenant(active);
                 if (active) {
                     localStorage.setItem('kb_active_tenant_id', active.tenantId);
@@ -88,6 +90,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                         tenantId: data.tenantId,
                         tenant_name: data.tenantName,
                         isNotebookEnabled: data.isNotebookEnabled ?? true,
+                        displayName: data.displayName,
                     });
                     await fetchTenants(apiKey, data.tenantId);
                 } else {
@@ -116,6 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             tenantId: userData.tenantId,
             tenant_name: (userData as any).tenantName || userData.tenant_name,
             isNotebookEnabled: userData.isNotebookEnabled ?? true,
+            displayName: userData.displayName,
         });
         fetchTenants(key, userData.tenantId);
     };

+ 31 - 0
web/types.ts

@@ -140,6 +140,7 @@ export interface Note {
   user?: {
     id: string;
     username: string;
+    name?: string;
   };
 }
 
@@ -269,6 +270,9 @@ export interface AppSettings {
 
   // Coach
   coachKbId?: string;
+
+  // Vision
+  selectedVisionId?: string;
 }
 
 // Default Models (Frontend specific)
@@ -296,3 +300,30 @@ export const DEFAULT_SETTINGS: AppSettings = {
 };
 
 export const API_BASE_URL = '/api'
+
+export interface Tenant {
+  id: string;
+  name: string;
+  domain?: string;
+  parentId?: string | null;
+  children?: Tenant[];
+  members?: TenantMember[];
+  settings_obj?: any;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface TenantMember {
+  id: string;
+  userId: string;
+  tenantId: string;
+  role: string;
+  user?: {
+    id: string;
+    username: string;
+    displayName?: string;
+    email?: string;
+  };
+  createdAt: string;
+  updatedAt: string;
+}

+ 99 - 11
web/utils/translations.ts

@@ -138,7 +138,11 @@ export const translations = {
     citationSources: "引用源",
     chunkNumber: "片段",
     getUserListFailed: "获取用户列表失败",
-    usernamePasswordRequired: "用户名和密码不能为空",
+    displayName: '显示名称',
+    displayNamePlaceholder: '输入完整姓名或显示名称',
+    globalUserNote: '注意',
+    roleManagedInOrg: '用户的角色权限主要在所属组织内管理。',
+    usernamePasswordRequired: '用户名和密码不能为空',
     passwordMinLength: "密码长度不能少于6位",
     userCreatedSuccess: "用户创建成功",
     createUserFailed: "创建用户失败",
@@ -148,7 +152,10 @@ export const translations = {
     confirmDeleteUser: "确定要删除此用户吗?",
     deleteUser: "删除用户",
     deleteUserFailed: "删除用户失败",
-    userDeletedSuccessfully: "用户删除成功",
+    editUser: "编辑用户",
+    organizations: "所属组织",
+    saveChanges: "保存更改",
+    edit: "编辑",
     makeUserAdmin: "设为管理员",
     makeUserRegular: "设为普通用户",
     loading: "加载中...",
@@ -208,7 +215,8 @@ export const translations = {
     changePassword: "修改密码",
     userManagement: "用户管理",
     userList: "用户列表",
-    addUser: "新增用户",
+    addUser: "添加用户",
+    userInfo: "用户信息",
     username: "用户名",
     password: "密码",
     confirmPassword: "确认密码",
@@ -221,6 +229,7 @@ export const translations = {
     confirmChange: "确认修改",
     changeUserPassword: "修改用户密码",
     enterNewPassword: "请输入新密码",
+    userDeletedSuccessfully: "用户已成功删除",
     createdAt: "创建时间",
     newChat: "新建对话",
 
@@ -399,6 +408,8 @@ export const translations = {
     navTenants: "租户管理",
     navSystemModels: "系统模型",
     navTenantManagement: "租户管理",
+    defaultTenant: "默认租户",
+    selectOrganization: "选择组织",
     navUsersTeam: "用户与团队",
     navTenantSettings: "租户设置",
     adminConsole: "管理控制台",
@@ -637,8 +648,27 @@ export const translations = {
     orgManagement: "组织管理",
     globalTenantControl: "全局租户控制",
     newTenant: "新租户",
+    tenantName: "租户名称",
     domainOptional: "域名 (可选)",
-    saveChanges: "保存修改",
+    noOrganizations: "暂无组织",
+    activeOrg: "当前组织",
+    noCustomDomain: "未绑定自定义域名",
+    orgSettings: "组织设置",
+    deleteOrg: "删除组织",
+    orgMembers: "组织成员",
+    membersCount: "$1 个成员",
+    noMembersAssigned: "未分配成员",
+    addMembers: "添加成员",
+    searchSystemUsers: "搜索系统用户...",
+    editOrg: "编辑组织",
+    parentOrg: "上级组织",
+    noneRoot: "无 (根组织)",
+    selectOrg: "选择一个组织",
+    selectOrgDesc: "从左侧目录中选择一个组织以管理其成员、设置和管理权限。",
+    totalSystemUsers: "系统用户总数",
+    rootOrgs: "根组织",
+    createSubOrg: "创建子组织",
+    update: "更新",
     modelConfiguration: "模型配置",
     defaultLLMModel: "默认推理模型",
     selectLLM: "选择 LLM",
@@ -787,6 +817,7 @@ export const translations = {
     scheduledAt: "计划执行时间",
     confirmDeleteTask: "确定要删除此导入任务记录吗?",
     deleteTaskFailed: "删除任务记录失败",
+    noOrganization: "暂无组织",
   },
   en: {
     aiCommandsError: "An error occurred",
@@ -860,7 +891,9 @@ export const translations = {
     navTenants: "Tenants",
     navSystemModels: "System Models",
     navTenantManagement: "Tenant Management",
-    navUsersTeam: "Users & Team",
+    defaultTenant: "Default Tenant",
+    selectOrganization: "Select Organization",
+    navUsersTeam: "Users & Teams",
     navTenantSettings: "Tenant Settings",
     adminConsole: "Admin Console",
     globalDashboard: "Global Dashboard",
@@ -961,7 +994,14 @@ export const translations = {
     citationSources: "Citation Sources",
     chunkNumber: "Chunk",
     getUserListFailed: "Failed to get user list",
-    usernamePasswordRequired: "Username and password cannot be empty",
+    displayName: 'Display Name',
+    displayNamePlaceholder: 'Enter full name or display name',
+    globalUserNote: 'Note',
+    roleManagedInOrg: 'User roles are primarily managed within organizations.',
+    editUser: 'Edit User',
+    edit: 'Edit',
+    saveChanges: 'Save Changes',
+    usernamePasswordRequired: 'Username and password cannot be empty',
     passwordMinLength: "Password must be at least 6 characters",
     userCreatedSuccess: "User created successfully",
     createUserFailed: "Failed to create user",
@@ -1059,6 +1099,7 @@ export const translations = {
     userManagement: "User Management",
     userList: "User List",
     addUser: "Add User",
+    userInfo: "User Info",
     username: "Username",
     password: "Password",
     confirmPassword: "Confirm Password",
@@ -1431,8 +1472,27 @@ export const translations = {
     orgManagement: "Organization Management",
     globalTenantControl: "Global Tenant Control",
     newTenant: "New Tenant",
+    tenantName: "Tenant Name",
     domainOptional: "Domain (Optional)",
-    saveChanges: "Save changes",
+    noOrganizations: "No Organizations",
+    activeOrg: "Active Org",
+    noCustomDomain: "No custom domain linked",
+    orgSettings: "Organization Settings",
+    deleteOrg: "Delete Organization",
+    orgMembers: "Organization Members",
+    membersCount: "$1 Members",
+    noMembersAssigned: "No members assigned",
+    addMembers: "Add Members",
+    searchSystemUsers: "Search system users...",
+    editOrg: "Edit Organization",
+    parentOrg: "Parent Organization",
+    noneRoot: "None (Root)",
+    selectOrg: "Select an Organization",
+    selectOrgDesc: "Choose an organization from the left directory to manage its members, settings, and administrative access.",
+    totalSystemUsers: "Total System Users",
+    rootOrgs: "Root Organizations",
+    createSubOrg: "Create Sub-organization",
+    update: "Update",
     modelConfiguration: "Model Configuration",
     defaultLLMModel: "Default LLM Model",
     selectLLM: "Select LLM",
@@ -1586,6 +1646,7 @@ export const translations = {
     scheduledAt: "Scheduled At",
     confirmDeleteTask: "Are you sure you want to delete this import task record?",
     deleteTaskFailed: "Failed to delete task record",
+    noOrganization: "No Organization",
   },
   ja: {
     aiCommandsError: "エラーが発生しました",
@@ -1720,7 +1781,14 @@ export const translations = {
     citationSources: "引用元",
     chunkNumber: "フラグメント",
     getUserListFailed: "ユーザー一覧の取得に失敗しました",
-    usernamePasswordRequired: "ユーザー名とパスワードは必須です",
+    displayName: '表示名',
+    displayNamePlaceholder: '氏名または表示名を入力',
+    globalUserNote: '注意',
+    roleManagedInOrg: 'ユーザーの役割(ロール)は、所属する組織内で管理されます。',
+    editUser: 'ユーザー編集',
+    edit: '編集',
+    saveChanges: '変更を保存',
+    usernamePasswordRequired: 'ユーザー名とパスワードは必須です',
     passwordMinLength: "パスワードは6文字以上で入力してください",
     userCreatedSuccess: "ユーザーが作成されました",
     createUserFailed: "ユーザー作成に失敗しました",
@@ -1817,7 +1885,7 @@ export const translations = {
     changePassword: "パスワード変更",
     userManagement: "ユーザー管理",
     userList: "ユーザー一覧",
-    addUser: "ユーザー追加",
+    userInfo: "ユーザー情報",
     username: "ユーザー名",
     password: "パスワード",
     confirmPassword: "パスワード確認",
@@ -2196,8 +2264,27 @@ export const translations = {
     orgManagement: "組織管理",
     globalTenantControl: "グローバルテナントコントロール",
     newTenant: "新規テナント",
-    domainOptional: "ドメイン (任意)",
-    saveChanges: "変更を保存",
+    tenantName: "テナント名",
+    domainOptional: "ドメイン (オプション)",
+    noOrganizations: "組織がありません",
+    activeOrg: "現在の組織",
+    noCustomDomain: "カスタムドメインがリンクされていません",
+    orgSettings: "組織設定",
+    deleteOrg: "組織を削除",
+    orgMembers: "組織メンバー",
+    membersCount: "$1 メンバー",
+    noMembersAssigned: "メンバーが割り当てられていません",
+    addMembers: "メンバーを追加",
+    searchSystemUsers: "システムユーザーを検索...",
+    editOrg: "組織を編集",
+    parentOrg: "親組織",
+    noneRoot: "なし (ルート)",
+    selectOrg: "組織を選択してください",
+    selectOrgDesc: "左側のディレクトリから組織を選択して、そのメンバー、設定、および管理アクセスを管理します。",
+    totalSystemUsers: "システムユーザーの総数",
+    rootOrgs: "ルート組織",
+    createSubOrg: "サブ組織を作成",
+    update: "更新",
     modelConfiguration: "モデル設定",
     defaultLLMModel: "デフォルト推論モデル",
     selectLLM: "LLMを選択",
@@ -2375,5 +2462,6 @@ export const translations = {
     scheduledAt: "実行予定日時",
     confirmDeleteTask: "このインポートタスクレコードを削除してもよろしいですか?",
     deleteTaskFailed: "タスクレコードの削除に失敗しました",
+    noOrganization: "組織なし",
   },
 };

部分文件因文件數量過多而無法顯示