anhuiqiang 3 недель назад
Родитель
Сommit
25f2cf0100
39 измененных файлов с 664 добавлено и 227 удалено
  1. 1 1
      server/src/admin/admin.controller.ts
  2. 4 1
      server/src/app.module.ts
  3. 58 6
      server/src/auth/combined-auth.guard.ts
  4. 25 2
      server/src/auth/jwt-auth.guard.ts
  5. 3 0
      server/src/auth/jwt.strategy.ts
  6. 1 1
      server/src/auth/roles.decorator.ts
  7. 1 1
      server/src/auth/roles.guard.ts
  8. 1 1
      server/src/auth/super-admin.guard.ts
  9. 1 1
      server/src/auth/tenant-admin.guard.ts
  10. 9 2
      server/src/elasticsearch/elasticsearch.service.ts
  11. 1 1
      server/src/knowledge-base/knowledge-base.controller.ts
  12. 1 1
      server/src/knowledge-base/knowledge-base.service.ts
  13. 1 1
      server/src/knowledge-group/knowledge-group.controller.ts
  14. 1 1
      server/src/model-config/model-config.controller.ts
  15. 8 0
      server/src/podcasts/entities/podcast-episode.entity.ts
  16. 8 0
      server/src/search-history/chat-message.entity.ts
  17. 1 1
      server/src/super-admin/super-admin.controller.ts
  18. 15 6
      server/src/super-admin/super-admin.service.ts
  19. 39 0
      server/src/tenant/tenant-entity.subscriber.ts
  20. 49 0
      server/src/tenant/tenant-member.entity.ts
  21. 3 2
      server/src/tenant/tenant.entity.ts
  22. 16 0
      server/src/tenant/tenant.middleware.ts
  23. 5 3
      server/src/tenant/tenant.module.ts
  24. 25 1
      server/src/tenant/tenant.service.ts
  25. 8 0
      server/src/tenant/tenant.store.ts
  26. 6 6
      server/src/user-setting/user-setting.entity.ts
  27. 1 1
      server/src/user/dto/user-safe.dto.ts
  28. 5 0
      server/src/user/user-role.enum.ts
  29. 14 2
      server/src/user/user.controller.ts
  30. 7 10
      server/src/user/user.entity.ts
  31. 2 1
      server/src/user/user.module.ts
  32. 21 3
      server/src/user/user.service.ts
  33. 69 69
      server/src/vision-pipeline/vision-pipeline.service.ts
  34. 84 47
      web/components/views/SettingsView.tsx
  35. 4 0
      web/services/apiClient.ts
  36. 73 11
      web/src/components/layouts/WorkspaceLayout.tsx
  37. 56 7
      web/src/contexts/AuthContext.tsx
  38. 33 33
      web/types.ts
  39. 4 4
      web/utils/uuid.ts

+ 1 - 1
server/src/admin/admin.controller.ts

@@ -3,7 +3,7 @@ import { AdminService } from './admin.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Controller('v1/admin')
 @UseGuards(CombinedAuthGuard, RolesGuard)

+ 4 - 1
server/src/app.module.ts

@@ -30,6 +30,7 @@ import { NoteModule } from './note/note.module';
 import { PodcastModule } from './podcasts/podcast.module';
 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 { ModelConfig } from './model-config/model-config.entity';
@@ -44,6 +45,7 @@ import { ImportTask } from './import-task/import-task.entity';
 import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
 import { ApiKey } from './auth/entities/api-key.entity';
+import { TenantMember } from './tenant/tenant-member.entity';
 import { TenantModule } from './tenant/tenant.module';
 import { SuperAdminModule } from './super-admin/super-admin.module';
 import { AdminModule } from './admin/admin.module';
@@ -79,6 +81,7 @@ import { AdminModule } from './admin/admin.module';
           ImportTask,
           Tenant,
           TenantSetting,
+          TenantMember,
           ApiKey,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
@@ -120,7 +123,7 @@ import { AdminModule } from './admin/admin.module';
 export class AppModule implements NestModule {
   configure(consumer: MiddlewareConsumer) {
     consumer
-      .apply(I18nMiddleware)
+      .apply(I18nMiddleware, TenantMiddleware)
       .forRoutes('*');
   }
 }

+ 58 - 6
server/src/auth/combined-auth.guard.ts

@@ -4,6 +4,7 @@ import { AuthGuard } from '@nestjs/passport';
 import { UserService } from '../user/user.service';
 import { Request } from 'express';
 import { IS_PUBLIC_KEY } from './public.decorator';
+import { tenantStore } from '../tenant/tenant.store';
 
 /**
  * A combined authentication guard that accepts either:
@@ -41,13 +42,37 @@ export class CombinedAuthGuard implements CanActivate {
         if (apiKey) {
             const user = await this.userService.findByApiKey(apiKey);
             if (user) {
+                // If x-tenant-id is provided, verify membership
+                const requestedTenantId = request.headers['x-tenant-id'] as string;
+                let activeTenantId = user.tenantId;
+
+                if (requestedTenantId) {
+                    const memberships = await this.userService.getUserTenants(user.id);
+                    const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
+                    if (hasAccess) {
+                        activeTenantId = requestedTenantId;
+                    } else if (user.role !== 'SUPER_ADMIN') {
+                        throw new UnauthorizedException('User does not belong to the requested tenant');
+                    } else {
+                        activeTenantId = requestedTenantId; // Super Admin can access any tenant
+                    }
+                }
+
                 request.user = {
                     id: user.id,
                     username: user.username,
                     role: user.role,
-                    tenantId: user.tenantId,
+                    tenantId: activeTenantId,
                 };
-                request.tenantId = user.tenantId;
+                request.tenantId = activeTenantId;
+
+                // Update tenant context store
+                const store = tenantStore.getStore();
+                if (store) {
+                    store.tenantId = activeTenantId;
+                    store.userId = user.id;
+                }
+
                 return true;
             }
             throw new UnauthorizedException('Invalid API key');
@@ -55,10 +80,37 @@ export class CombinedAuthGuard implements CanActivate {
 
         // --- Fall back to JWT ---
         try {
-            const result = await (this.jwtGuard as any).canActivate(context);
-            return result as boolean;
-        } catch {
-            throw new UnauthorizedException('Authentication required');
+            const hasJwtSession = await (this.jwtGuard as any).canActivate(context);
+            if (hasJwtSession) {
+                const user = request.user;
+                const requestedTenantId = request.headers['x-tenant-id'] as string;
+
+                if (requestedTenantId && user.tenantId !== requestedTenantId) {
+                    // Refresh membership check for JWT users if switching tenant
+                    const memberships = await this.userService.getUserTenants(user.id);
+                    const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
+
+                    if (hasAccess || user.role === 'SUPER_ADMIN') {
+                        user.tenantId = requestedTenantId;
+                    } else {
+                        throw new UnauthorizedException('User does not belong to the requested tenant');
+                    }
+                }
+
+                request.tenantId = user.tenantId;
+
+                // Update tenant context store
+                const store = tenantStore.getStore();
+                if (store) {
+                    store.tenantId = user.tenantId;
+                    store.userId = user.id;
+                }
+
+                return true;
+            }
+            return false;
+        } catch (e) {
+            throw e instanceof UnauthorizedException ? e : new UnauthorizedException('Authentication required');
         }
     }
 

+ 25 - 2
server/src/auth/jwt-auth.guard.ts

@@ -1,7 +1,9 @@
 import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { AuthGuard } from '@nestjs/passport';
+import { lastValueFrom, Observable } from 'rxjs';
 import { IS_PUBLIC_KEY } from './public.decorator';
+import { tenantStore } from '../tenant/tenant.store';
 
 @Injectable()
 export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
@@ -9,7 +11,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
     super();
   }
 
-  canActivate(context: ExecutionContext) {
+  async canActivate(context: ExecutionContext): Promise<boolean> {
     const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
       context.getHandler(),
       context.getClass(),
@@ -17,6 +19,27 @@ export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
     if (isPublic) {
       return true;
     }
-    return super.canActivate(context);
+
+    const result = await super.canActivate(context);
+    let canActivate = false;
+
+    if (result instanceof Observable) {
+      canActivate = await lastValueFrom(result);
+    } else {
+      canActivate = result;
+    }
+
+    if (canActivate) {
+      const request = context.switchToHttp().getRequest();
+      const user = request.user;
+      if (user) {
+        const store = tenantStore.getStore();
+        if (store) {
+          store.tenantId = user.tenantId;
+          store.userId = user.id;
+        }
+      }
+    }
+    return canActivate;
   }
 }

+ 3 - 0
server/src/auth/jwt.strategy.ts

@@ -28,6 +28,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
     const user = await this.userService.findOneByUsername(payload.username);
     if (user) {
       const { password, ...result } = user;
+
+      // 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.
       return {
         ...result,
         role: payload.role || result.role,

+ 1 - 1
server/src/auth/roles.decorator.ts

@@ -1,5 +1,5 @@
 import { SetMetadata } from '@nestjs/common';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 export const ROLES_KEY = 'roles';
 export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

+ 1 - 1
server/src/auth/roles.guard.ts

@@ -1,7 +1,7 @@
 import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { ROLES_KEY } from './roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class RolesGuard implements CanActivate {

+ 1 - 1
server/src/auth/super-admin.guard.ts

@@ -1,5 +1,5 @@
 import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class SuperAdminGuard implements CanActivate {

+ 1 - 1
server/src/auth/tenant-admin.guard.ts

@@ -1,5 +1,5 @@
 import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class TenantAdminGuard implements CanActivate {

+ 9 - 2
server/src/elasticsearch/elasticsearch.service.ts

@@ -598,14 +598,21 @@ export class ElasticsearchService implements OnModuleInit {
   /**
    * 指定されたファイルのすべてのチャンクを取得
    */
-  async getFileChunks(fileId: string) {
+  async getFileChunks(fileId: string, userId: string, tenantId?: string) {
     try {
       this.logger.log(`Getting chunks for file ${fileId}`);
 
+      const filter: any[] = [{ term: { fileId } }];
+      if (tenantId) {
+        filter.push({ term: { tenantId } });
+      } else {
+        filter.push({ term: { userId } });
+      }
+
       const response = await this.client.search({
         index: this.indexName,
         query: {
-          term: { fileId },
+          bool: { filter },
         },
         sort: [{ chunkIndex: 'asc' }],
         size: 10000, // 単一ファイルが 10000 チャンクを超えないと想定

+ 1 - 1
server/src/knowledge-base/knowledge-base.controller.ts

@@ -19,7 +19,7 @@ import { KnowledgeBaseService } from './knowledge-base.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 import { Public } from '../auth/public.decorator';
 import { KnowledgeBase } from './knowledge-base.entity';
 import { ChunkConfigService } from './chunk-config.service';

+ 1 - 1
server/src/knowledge-base/knowledge-base.service.ts

@@ -1172,7 +1172,7 @@ export class KnowledgeBaseService {
     }
 
     // 2. Elasticsearch からすべてのチャンクを取得
-    const chunks = await this.elasticsearchService.getFileChunks(fileId);
+    const chunks = await this.elasticsearchService.getFileChunks(fileId, userId, tenantId);
 
     // 3. チャンク情報を返却
     return {

+ 1 - 1
server/src/knowledge-group/knowledge-group.controller.ts

@@ -12,7 +12,7 @@ import {
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 import { KnowledgeGroupService, CreateGroupDto, UpdateGroupDto } from './knowledge-group.service';
 
 @Controller('knowledge-groups')

+ 1 - 1
server/src/model-config/model-config.controller.ts

@@ -19,7 +19,7 @@ import { UpdateModelConfigDto } from './dto/update-model-config.dto';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 import { ModelConfigResponseDto } from './dto/model-config-response.dto';
 import { plainToClass } from 'class-transformer';
 

+ 8 - 0
server/src/podcasts/entities/podcast-episode.entity.ts

@@ -9,6 +9,7 @@ import {
 } from 'typeorm';
 import { User } from '../../user/user.entity';
 import { KnowledgeGroup } from '../../knowledge-group/knowledge-group.entity';
+import { Tenant } from '../../tenant/tenant.entity';
 
 export enum PodcastStatus {
     PENDING = 'pending',
@@ -47,6 +48,9 @@ export class PodcastEpisode {
     @Column({ name: 'group_id', nullable: true })
     groupId: string;
 
+    @Column({ name: 'tenant_id', nullable: true, type: 'text' })
+    tenantId: string;
+
     @CreateDateColumn({ name: 'created_at' })
     createdAt: Date;
 
@@ -60,4 +64,8 @@ export class PodcastEpisode {
     @ManyToOne(() => KnowledgeGroup)
     @JoinColumn({ name: 'group_id' })
     group: KnowledgeGroup;
+
+    @ManyToOne(() => Tenant)
+    @JoinColumn({ name: 'tenant_id' })
+    tenant: Tenant;
 }

+ 8 - 0
server/src/search-history/chat-message.entity.ts

@@ -7,6 +7,7 @@ import {
   JoinColumn,
 } from 'typeorm';
 import { SearchHistory } from './search-history.entity';
+import { Tenant } from '../tenant/tenant.entity';
 
 @Entity('chat_messages')
 export class ChatMessage {
@@ -16,6 +17,9 @@ export class ChatMessage {
   @Column({ name: 'search_history_id' })
   searchHistoryId: string;
 
+  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
+  tenantId: string;
+
   @Column()
   role: 'user' | 'assistant';
 
@@ -31,4 +35,8 @@ export class ChatMessage {
   @ManyToOne(() => SearchHistory, (history) => history.messages)
   @JoinColumn({ name: 'search_history_id' })
   searchHistory: SearchHistory;
+
+  @ManyToOne(() => Tenant)
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
 }

+ 1 - 1
server/src/super-admin/super-admin.controller.ts

@@ -3,7 +3,7 @@ import { SuperAdminService } from './super-admin.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Controller('v1/tenants')
 @UseGuards(CombinedAuthGuard, RolesGuard)

+ 15 - 6
server/src/super-admin/super-admin.service.ts

@@ -1,7 +1,8 @@
 import { Injectable, NotFoundException } from '@nestjs/common';
 import { TenantService } from '../tenant/tenant.service';
 import { UserService } from '../user/user.service';
-import { UserRole } from '../user/user.entity';
+import { User } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class SuperAdminService {
@@ -17,16 +18,24 @@ export class SuperAdminService {
     async createTenant(name: string, domain?: string, adminUserId?: string) {
         const tenant = await this.tenantService.create(name, domain);
         if (adminUserId) {
-            await this.assignUserToTenant(adminUserId, tenant.id, UserRole.TENANT_ADMIN);
+            await this.tenantService.addMember(tenant.id, adminUserId, UserRole.TENANT_ADMIN);
         }
         return tenant;
     }
 
     async assignUserToTenant(userId: string, tenantId: string, role: UserRole = UserRole.TENANT_ADMIN) {
-        return this.userService.updateUser(userId, {
-            tenantId,
-            role,
-        });
+        // Find existing members of this tenant
+        const members = await this.tenantService.getMembers(tenantId);
+
+        // Remove existing admins from this tenant (unlinking them, not changing their role)
+        for (const member of members) {
+            if (member.role === UserRole.TENANT_ADMIN || member.role === UserRole.SUPER_ADMIN) {
+                await this.tenantService.removeMember(tenantId, member.userId);
+            }
+        }
+
+        // Add the new admin association for this tenant
+        return this.tenantService.addMember(tenantId, userId, role);
     }
 
     async createTenantAdmin(tenantId: string, username: string, password?: string) {

+ 39 - 0
server/src/tenant/tenant-entity.subscriber.ts

@@ -0,0 +1,39 @@
+import {
+    EntitySubscriberInterface,
+    EventSubscriber,
+    InsertEvent,
+    UpdateEvent,
+} from 'typeorm';
+import { tenantStore } from './tenant.store';
+
+@EventSubscriber()
+export class TenantEntitySubscriber implements EntitySubscriberInterface {
+    /**
+     * Called before entity insertion.
+     */
+    beforeInsert(event: InsertEvent<any>) {
+        this.injectTenantId(event.entity);
+    }
+
+    /**
+     * Called before entity update.
+     */
+    beforeUpdate(event: UpdateEvent<any>) {
+        this.injectTenantId(event.entity);
+    }
+
+    private injectTenantId(entity: any) {
+        if (!entity) return;
+
+        // Check if the entity has a tenantId property
+        if ('tenantId' in entity) {
+            const store = tenantStore.getStore();
+            const currentTenantId = store?.tenantId;
+
+            // Only set if it's not already set and we have a tenantId in context
+            if (!entity.tenantId && currentTenantId) {
+                entity.tenantId = currentTenantId;
+            }
+        }
+    }
+}

+ 49 - 0
server/src/tenant/tenant-member.entity.ts

@@ -0,0 +1,49 @@
+import {
+    Column,
+    CreateDateColumn,
+    Entity,
+    JoinColumn,
+    ManyToOne,
+    PrimaryGeneratedColumn,
+    UpdateDateColumn,
+} from 'typeorm';
+import { User } from '../user/user.entity';
+import { UserRole } from '../user/user-role.enum';
+import { Tenant } from './tenant.entity';
+
+/**
+ * Join table for User and Tenant to support Many-to-Many relationship.
+ * A user can belong to multiple tenants with different roles in each.
+ */
+@Entity('tenant_members')
+export class TenantMember {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column({ name: 'user_id' })
+    userId: string;
+
+    @ManyToOne(() => User, (user) => user.tenantMembers, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'user_id' })
+    user: User;
+
+    @Column({ name: 'tenant_id' })
+    tenantId: string;
+
+    @ManyToOne(() => Tenant, (tenant) => tenant.members, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'tenant_id' })
+    tenant: Tenant;
+
+    @Column({
+        type: 'simple-enum',
+        enum: UserRole,
+        default: UserRole.USER,
+    })
+    role: UserRole;
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

+ 3 - 2
server/src/tenant/tenant.entity.ts

@@ -7,6 +7,7 @@ import {
     UpdateDateColumn,
 } from 'typeorm';
 import { User } from '../user/user.entity';
+import { TenantMember } from './tenant-member.entity';
 
 @Entity('tenants')
 export class Tenant {
@@ -25,8 +26,8 @@ export class Tenant {
     @Column({ name: 'default_model_id', type: 'text', nullable: true })
     defaultModelId: string;
 
-    @OneToMany(() => User, (user) => user.tenant)
-    users: User[];
+    @OneToMany(() => TenantMember, (member) => member.tenant)
+    members: TenantMember[];
 
     @CreateDateColumn({ name: 'created_at' })
     createdAt: Date;

+ 16 - 0
server/src/tenant/tenant.middleware.ts

@@ -0,0 +1,16 @@
+import { Injectable, NestMiddleware } from '@nestjs/common';
+import { Request, Response, NextFunction } from 'express';
+import { tenantStore } from './tenant.store';
+
+@Injectable()
+export class TenantMiddleware implements NestMiddleware {
+    use(req: Request, res: Response, next: NextFunction) {
+        const tenantId = req.headers['x-tenant-id'] as string;
+
+        // Wrap the execution in the tenant store context
+        // Initial values might be empty or partial until the AuthGuard validates them
+        tenantStore.run({ tenantId: tenantId || '' }, () => {
+            next();
+        });
+    }
+}

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

@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { Tenant } from './tenant.entity';
 import { TenantSetting } from './tenant-setting.entity';
-import { TenantService } from './tenant.service';
+import { TenantMember } from './tenant-member.entity';
 import { TenantController } from './tenant.controller';
+import { TenantService } from './tenant.service';
+import { TenantEntitySubscriber } from './tenant-entity.subscriber';
 
 @Module({
-    imports: [TypeOrmModule.forFeature([Tenant, TenantSetting])],
-    providers: [TenantService],
+    imports: [TypeOrmModule.forFeature([Tenant, TenantSetting, TenantMember])],
+    providers: [TenantService, TenantEntitySubscriber],
     controllers: [TenantController],
     exports: [TenantService],
 })

+ 25 - 1
server/src/tenant/tenant.service.ts

@@ -7,6 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { Tenant } from './tenant.entity';
 import { TenantSetting } from './tenant-setting.entity';
+import { TenantMember } from './tenant-member.entity';
 
 @Injectable()
 export class TenantService {
@@ -15,11 +16,13 @@ export class TenantService {
         private readonly tenantRepository: Repository<Tenant>,
         @InjectRepository(TenantSetting)
         private readonly tenantSettingRepository: Repository<TenantSetting>,
+        @InjectRepository(TenantMember)
+        private readonly tenantMemberRepository: Repository<TenantMember>,
     ) { }
 
     async findAll(): Promise<Tenant[]> {
         return this.tenantRepository.find({
-            relations: ['users'],
+            relations: ['members', 'members.user'],
             order: { createdAt: 'ASC' }
         });
     }
@@ -78,6 +81,27 @@ export class TenantService {
         return this.tenantSettingRepository.save(setting);
     }
 
+    async addMember(tenantId: string, userId: string, role: string = 'USER'): Promise<TenantMember> {
+        const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
+        if (existing) {
+            existing.role = role as any;
+            return this.tenantMemberRepository.save(existing);
+        }
+        const member = this.tenantMemberRepository.create({ tenantId, userId, role: role as any });
+        return this.tenantMemberRepository.save(member);
+    }
+
+    async removeMember(tenantId: string, userId: string): Promise<void> {
+        await this.tenantMemberRepository.delete({ tenantId, userId });
+    }
+
+    async getMembers(tenantId: string): Promise<TenantMember[]> {
+        return this.tenantMemberRepository.find({
+            where: { tenantId },
+            relations: ['user'],
+        });
+    }
+
     /**
      * Ensure a "Default" tenant exists for data migration purposes.
      * Called during app bootstrap.

+ 8 - 0
server/src/tenant/tenant.store.ts

@@ -0,0 +1,8 @@
+import { AsyncLocalStorage } from 'async_hooks';
+
+export interface TenantContext {
+    tenantId: string;
+    userId?: string;
+}
+
+export const tenantStore = new AsyncLocalStorage<TenantContext>();

+ 6 - 6
server/src/user-setting/user-setting.entity.ts

@@ -25,25 +25,25 @@ export class UserSetting {
   @Column({ type: 'boolean', default: false })
   isGlobal: boolean;
 
-  @Column({ type: 'text' })
+  @Column({ type: 'text', default: 'gpt-3.5-turbo' })
   selectedLLMId: string;
 
-  @Column({ type: 'text' })
+  @Column({ type: 'text', default: 'text-embedding-3-small' })
   selectedEmbeddingId: string;
 
   @Column({ type: 'text', nullable: true })
   selectedRerankId: string;
 
-  @Column({ type: 'real' })
+  @Column({ type: 'real', default: 0.7 })
   temperature: number;
 
-  @Column({ type: 'integer' })
+  @Column({ type: 'integer', default: 2048 })
   maxTokens: number;
 
-  @Column({ type: 'boolean' })
+  @Column({ type: 'boolean', default: false })
   enableRerank: boolean;
 
-  @Column({ type: 'integer' })
+  @Column({ type: 'integer', default: 5 })
   topK: number;
 
   @Column({ type: 'real', default: 0.3 })

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

@@ -1,6 +1,6 @@
 // server/src/user/dto/user-safe.dto.ts
 
-import { UserRole } from '../user.entity';
+import { UserRole } from '../user-role.enum';
 
 export type SafeUser = {
   id: string;

+ 5 - 0
server/src/user/user-role.enum.ts

@@ -0,0 +1,5 @@
+export enum UserRole {
+    SUPER_ADMIN = 'SUPER_ADMIN',
+    TENANT_ADMIN = 'TENANT_ADMIN',
+    USER = 'USER',
+}

+ 14 - 2
server/src/user/user.controller.ts

@@ -39,10 +39,20 @@ export class UserController {
   }
 
   // --- Profile ---
+  @Get('profile')
+  async getProfile(@Request() req: any) {
+    return this.userService.findOneById(req.user.id);
+  }
+
+  @Get('tenants')
+  async getMyTenants(@Request() req: any) {
+    return this.userService.getUserTenants(req.user.id);
+  }
+
   @Get('me')
   async getMe(@Request() req) {
     const user = await this.userService.findOneById(req.user.id);
-    if (!user) throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
+    if (!user) throw new NotFoundException();
 
     let isNotebookEnabled = true;
     if (user.tenantId) {
@@ -50,12 +60,14 @@ export class UserController {
       isNotebookEnabled = settings?.isNotebookEnabled ?? true;
     }
 
+    const tenantName = user.tenantMembers?.[0]?.tenant?.name || 'Default';
+
     return {
       id: user.id,
       username: user.username,
       role: user.role,
       tenantId: user.tenantId,
-      tenantName: user.tenant?.name || 'Default',
+      tenantName,
       isAdmin: user.isAdmin,
       isNotebookEnabled,
     };

+ 7 - 10
server/src/user/user.entity.ts

@@ -14,13 +14,10 @@ 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';
 
-export enum UserRole {
-  SUPER_ADMIN = 'SUPER_ADMIN',
-  TENANT_ADMIN = 'TENANT_ADMIN',
-  USER = 'USER',
-}
+import { UserRole } from './user-role.enum';
 
 @Entity('users')
 export class User {
@@ -45,14 +42,14 @@ export class User {
   })
   role: UserRole;
 
-  // Multi-tenancy: Each user belongs to a tenant
+  // Multi-tenancy: A user can belong to multiple tenants via TenantMember
+  @OneToMany(() => TenantMember, (member) => member.user)
+  tenantMembers: TenantMember[];
+
+  // Legacy field - kept for backward compatibility if needed, but primary tenant is now determined by context
   @Column({ type: 'text', nullable: true, name: 'tenant_id' })
   tenantId: string;
 
-  @ManyToOne(() => Tenant, (tenant) => tenant.users, { nullable: true })
-  @JoinColumn({ name: 'tenant_id' })
-  tenant: Tenant;
-
   // API Keys for external API access
   @OneToMany(() => ApiKey, (apiKey) => apiKey.user)
   apiKeys: ApiKey[];

+ 2 - 1
server/src/user/user.module.ts

@@ -1,6 +1,7 @@
 import { Module, Global } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { User } from './user.entity';
+import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 import { UserService } from './user.service';
 import { UserController } from './user.controller';
@@ -9,7 +10,7 @@ import { TenantModule } from '../tenant/tenant.module';
 @Global()
 @Module({
   imports: [
-    TypeOrmModule.forFeature([User, ApiKey]),
+    TypeOrmModule.forFeature([User, ApiKey, TenantMember]),
     TenantModule,
   ],
   controllers: [UserController],

+ 21 - 3
server/src/user/user.service.ts

@@ -1,7 +1,9 @@
 import { BadRequestException, ConflictException, Injectable, NotFoundException, OnModuleInit, ForbiddenException, Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
-import { User, UserRole } from './user.entity';
+import { User } from './user.entity';
+import { UserRole } from './user-role.enum';
+import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 import * as bcrypt from 'bcrypt';
 import { CreateUserDto } from './dto/create-user.dto';
@@ -18,6 +20,8 @@ export class UserService implements OnModuleInit {
     private usersRepository: Repository<User>,
     @InjectRepository(ApiKey)
     private apiKeyRepository: Repository<ApiKey>,
+    @InjectRepository(TenantMember)
+    private tenantMemberRepository: Repository<TenantMember>,
     private i18nService: I18nService,
     private tenantService: TenantService,
   ) { }
@@ -106,7 +110,10 @@ export class UserService implements OnModuleInit {
   }
 
   async findOneById(userId: string): Promise<User | null> {
-    return this.usersRepository.findOne({ where: { id: userId }, relations: ['tenant'] });
+    return this.usersRepository.findOne({
+      where: { id: userId },
+      relations: ['tenantMembers', 'tenantMembers.tenant']
+    });
   }
 
   async findByApiKey(apiKeyValue: string): Promise<User | null> {
@@ -118,7 +125,18 @@ export class UserService implements OnModuleInit {
   }
 
   async findByTenantId(tenantId: string): Promise<User[]> {
-    return this.usersRepository.find({ where: { tenantId }, select: ['id', 'username', 'isAdmin', 'role', 'createdAt'] });
+    const members = await this.tenantMemberRepository.find({
+      where: { tenantId },
+      relations: ['user']
+    });
+    return members.map(m => m.user);
+  }
+
+  async getUserTenants(userId: string): Promise<TenantMember[]> {
+    return this.tenantMemberRepository.find({
+      where: { userId },
+      relations: ['tenant']
+    });
   }
 
   /**

+ 69 - 69
server/src/vision-pipeline/vision-pipeline.service.ts

@@ -30,8 +30,8 @@ export class VisionPipelineService {
   ) { }
 
   /**
-   * メイン処理フロー:精密モード
-   * 処理結果を返し、呼び出し側がベクトル化とインデックス作成を担当します
+   * Main processing flow: Precise mode
+   * Returns the processing result, and the caller is responsible for vectorization and indexing.
    */
   async processPreciseMode(
     filePath: string,
@@ -46,19 +46,19 @@ export class VisionPipelineService {
     let imagesToProcess: any[] = [];
 
     this.logger.log(
-      `🚀 精密モード処理を開始: ${options.fileName} (ユーザー: ${options.userId})`,
+      `🚀 Starting precise mode processing: ${options.fileName} (User: ${options.userId})`,
     );
 
     try {
-      // ステップ 1: 形式の統一
-      this.logger.log('📄 ステップ 1/4: 形式の統一');
-      this.updateStatus('converting', 10, 'ドキュメント形式を変換中...');
+      // Step 1: Unification of formats
+      this.logger.log('📄 Step 1/4: Unification of formats');
+      this.updateStatus('converting', 10, 'Converting document format...');
       pdfPath = await this.convertToPDF(filePath);
-      this.logger.log(`✅ 形式変換完了: ${pdfPath}`);
+      this.logger.log(`✅ Format conversion completed: ${pdfPath}`);
 
-      // ステップ 2: PDF から画像への変換
-      this.logger.log('🖼️  ステップ 2/4: PDF から画像への変換');
-      this.updateStatus('splitting', 30, 'PDF を画像に変換中...');
+      // Step 2: Conversion from PDF to images
+      this.logger.log('🖼️  Step 2/4: Conversion from PDF to images');
+      this.updateStatus('splitting', 30, 'Converting PDF to images...');
       const conversionResult = await this.pdf2Image.convertToImages(pdfPath, {
         density: 300,
         quality: 85,
@@ -66,37 +66,37 @@ export class VisionPipelineService {
       });
 
       if (conversionResult.images.length === 0) {
-        throw new Error('PDF から画像への変換に失敗しました。画像が生成されませんでした');
+        throw new Error('Failed to convert PDF to images. No images were generated.');
       }
 
       this.logger.log(
-        `✅ PDF から画像への変換完了: 合計 ${conversionResult.totalPages} ページ、${conversionResult.images.length} 枚の画像を生成`,
+        `✅ PDF to image conversion completed: Total ${conversionResult.totalPages} pages, ${conversionResult.images.length} images generated`,
       );
 
-      // 処理するページ数を制限
+      // Limit the number of pages to process
       imagesToProcess = options.maxPages
         ? conversionResult.images.slice(0, options.maxPages)
         : conversionResult.images;
 
       this.logger.log(
-        `📊 ${imagesToProcess.length} ページを処理します (${options.maxPages ? '制限あり' : 'すべて'})`,
+        `📊 Processing ${imagesToProcess.length} pages (${options.maxPages ? 'limited' : 'all'})`,
       );
 
-      // ステップ 3: Vision モデル設定の取得
-      this.logger.log('🤖 ステップ 3/4: Vision モデルの準備');
+      // Step 3: Get Vision model configuration
+      this.logger.log('🤖 Step 3/4: Preparation of Vision model');
       const modelConfig = await this.getVisionModelConfig(
         options.userId,
         options.modelId,
         options.tenantId,
       );
-      this.logger.log(`✅ Vision モデル設定完了: ${modelConfig.modelId}`);
+      this.logger.log(`✅ Vision model configuration completed: ${modelConfig.modelId}`);
 
-      // ステップ 4: VL モデル分析
-      this.logger.log('🔍 ステップ 4/4: Vision モデル分析');
-      this.updateStatus('analyzing', 50, 'ビジョンモデルを使用してページを分析中...');
+      // Step 4: VL model analysis
+      this.logger.log('🔍 Step 4/4: Vision model analysis');
+      this.updateStatus('analyzing', 50, 'Analyzing pages using Vision model...');
 
-      // 各ページの処理進捗を表示
-      this.logger.log(`${imagesToProcess.length} ページの内容を分析開始...`);
+      // Display processing progress of each page
+      this.logger.log(`Starting analysis of ${imagesToProcess.length} page contents...`);
       const batchResult = await this.vision.batchAnalyze(
         imagesToProcess.map((img) => img.path),
         modelConfig,
@@ -106,7 +106,7 @@ export class VisionPipelineService {
           onProgress: (current: number, total: number, pageResult?: any) => {
             const progress = Math.round((current / total) * 100);
             this.logger.log(
-              `📄 処理進捗: ${current}/${total} (${progress}%) ${pageResult ? `- ページ ${pageResult.pageIndex} 完了` : ''}`,
+              `📄 Processing progress: ${current}/${total} (${progress}%) ${pageResult ? `- Page ${pageResult.pageIndex} completed` : ''}`,
             );
           },
         },
@@ -118,31 +118,31 @@ export class VisionPipelineService {
       results.push(...batchResult.results);
 
       this.logger.log(
-        `✅ Vision 分析完了: 成功 ${processedPages} ページ、失敗 ${failedPages} ページ、コスト $${totalCost.toFixed(2)}`,
+        `✅ Vision analysis completed: Success ${processedPages} pages, Fail ${failedPages} pages, Cost $${totalCost.toFixed(2)}`,
       );
 
-      // ステップ 5: 一時ファイルのクリーンアップ(画像)
-      this.logger.log('🧹 一時ファイルをクリーンアップ中...');
-      this.updateStatus('completed', 100, '処理が完了しました。一時ファイルをクリーンアップ中...');
+      // Step 5: Cleanup of temporary files (images)
+      this.logger.log('🧹 Cleaning up temporary files...');
+      this.updateStatus('completed', 100, 'Processing completed. Cleaning up temporary files...');
       await this.pdf2Image.cleanupImages(imagesToProcess);
 
-      // PDF に変換した場合は、変換後のファイルをクリーンアップ
+      // If converted to PDF, clean up the converted file
       if (pdfPath !== filePath) {
         try {
           await fs.unlink(pdfPath);
-          this.logger.log('🗑️  変換後の PDF ファイルをクリーンアップしました');
+          this.logger.log('🗑️  Cleaned up converted PDF file');
         } catch (error) {
-          this.logger.warn(`⚠️  変換した PDF のクリーンアップに失敗しました: ${error.message}`);
+          this.logger.warn(`⚠️  Failed to clean up converted PDF: ${error.message}`);
         }
       }
 
       const duration = (Date.now() - startTime) / 1000;
 
       this.logger.log(
-        `🎉 精密モード処理完了! ` +
-        `📊 統計: ${processedPages}/${imagesToProcess.length} ページ成功, ` +
-        `💰 コスト: $${totalCost.toFixed(2)}, ` +
-        `⏱️  所要時間: ${duration.toFixed(1)}s`,
+        `🎉 Precise mode processing completed! ` +
+        `📊 Statistics: ${processedPages}/${imagesToProcess.length} pages success, ` +
+        `💰 Cost: $${totalCost.toFixed(2)}, ` +
+        `⏱️  Duration: ${duration.toFixed(1)}s`,
       );
 
       return {
@@ -158,9 +158,9 @@ export class VisionPipelineService {
         mode: 'precise',
       };
     } catch (error) {
-      this.logger.error(`❌ 精密モード処理失敗: ${error.message}`);
+      this.logger.error(`❌ Precise mode processing failed: ${error.message}`);
 
-      // 一時ファイルのクリーンアップを試行
+      // Attempting cleanup of temporary files
       try {
         if (pdfPath !== filePath && pdfPath !== filePath) {
           await fs.unlink(pdfPath);
@@ -168,9 +168,9 @@ export class VisionPipelineService {
         if (imagesToProcess.length > 0) {
           await this.pdf2Image.cleanupImages(imagesToProcess);
         }
-        this.logger.log('🧹 一時ファイルをクリーンアップしました');
+        this.logger.log('🧹 Cleaned up temporary files');
       } catch {
-        this.logger.warn('⚠️  一時ファイルのクリーンアップに失敗しました');
+        this.logger.warn('⚠️  Failed to clean up temporary files');
       }
 
       return {
@@ -189,7 +189,7 @@ export class VisionPipelineService {
   }
 
   /**
-   * Vision モデル設定の取得
+   * Get Vision model configuration
    */
   private async getVisionModelConfig(
     userId: string,
@@ -199,10 +199,10 @@ export class VisionPipelineService {
     const config = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
 
     if (!config) {
-      throw new Error(`モデル設定が見つかりません: ${modelId}`);
+      throw new Error(`Model configuration not found: ${modelId}`);
     }
 
-    // APIキーはオプションです - ローカルモデルを許可します
+    // API key is optional - Allows local models
 
     return {
       baseUrl: config.baseUrl || '',
@@ -212,47 +212,47 @@ export class VisionPipelineService {
   }
 
   /**
-   * PDF への変換
+   * Conversion to PDF
    */
   private async convertToPDF(filePath: string): Promise<string> {
     const ext = path.extname(filePath).toLowerCase();
 
-    // 既に PDF の場合はそのまま返す
+    // If already PDF, return as is
     if (ext === '.pdf') {
       return filePath;
     }
 
-    // LibreOffice を呼び出して変換
+    // Call LibreOffice to convert
     const containerPdfPath = await this.libreOffice.convertToPDF(filePath);
 
-    // LibreOffice コンテナから返されたパスは既に正しいです。すべて同じ uploads ディレクトリを指しているためです
-    // コンテナ内: /uploads/xxx.pdf -> ホストマシン: ../uploads/xxx.pdf
+    // The path returned from the LibreOffice container is already correct. All point to the same uploads directory.
+    // Inside container: /uploads/xxx.pdf -> Host machine: ../uploads/xxx.pdf
     const hostPdfPath = containerPdfPath.startsWith('/uploads/')
       ? path.join('..', containerPdfPath) // ../uploads/xxx.pdf
       : containerPdfPath;
 
-    this.logger.log(`パス変換: ${containerPdfPath} -> ${hostPdfPath}`);
+    this.logger.log(`Path conversion: ${containerPdfPath} -> ${hostPdfPath}`);
 
-    // ファイルの存在を確認
+    // Check existence of file
     try {
       await fs.access(hostPdfPath);
       return hostPdfPath;
     } catch (error) {
-      this.logger.error(`PDF ファイルが存在しません: ${hostPdfPath}`);
-      throw new Error(`PDF ファイルが存在しません: ${hostPdfPath}`);
+      this.logger.error(`PDF file does not exist: ${hostPdfPath}`);
+      throw new Error(`PDF file does not exist: ${hostPdfPath}`);
     }
   }
 
   /**
-   * 結果を Elasticsearch にインデックス(knowledge-base の embedding サービスを使用)
-   * 注意:このメソッドは embedding サービスを必要とするため、knowledge-base.service 内で呼び出す必要があります
+   * Index results to Elasticsearch (using knowledge-base embedding service)
+   * Note: This method requires the embedding service, so it should be called within knowledge-base.service.
    */
   private async indexResults(
     results: any[],
     options: PreciseModeOptions,
   ): Promise<void> {
-    // このメソッドは現在 knowledge-base.service から呼び出されます
-    // vision-pipeline は処理と結果の返却のみを担当します
+    // This method is currently called from knowledge-base.service
+    // vision-pipeline is only responsible for processing and returning results.
     this.logger.log(
       `indexResults called with ${results.length} results - should be handled by knowledge-base service`,
     );
@@ -262,7 +262,7 @@ export class VisionPipelineService {
   }
 
   /**
-   * 形式検出とモードの推奨
+   * Format detection and mode recommendation
    */
   async recommendMode(filePath: string): Promise<ModeRecommendation> {
     const ext = path.extname(filePath).toLowerCase();
@@ -283,49 +283,49 @@ export class VisionPipelineService {
     if (!supportedFormats.includes(ext)) {
       return {
         recommendedMode: 'fast',
-        reason: `サポートされていないファイル形式です: ${ext}`,
-        warnings: ['高速モード(テキスト抽出のみ)を使用します'],
+        reason: `Unsupported file format: ${ext}`,
+        warnings: ['Using fast mode (text extraction only)'],
       };
     }
 
     if (!preciseFormats.includes(ext)) {
       return {
         recommendedMode: 'fast',
-        reason: `形式 ${ext} は精密モードをサポートしていません`,
-        warnings: ['高速モード(テキスト抽出のみ)を使用します'],
+        reason: `Format ${ext} does not support precise mode`,
+        warnings: ['Using fast mode (text extraction only)'],
       };
     }
 
-    // ファイルサイズチェック
+    // File size check
     if (sizeMB > 50) {
       return {
         recommendedMode: 'precise',
-        reason: 'ファイルが大きいため、完全な情報を保持するために精密モードを推奨します',
-        estimatedCost: sizeMB * 0.01, // 粗い見積もり
-        estimatedTime: sizeMB * 12, // 1MB あたり約 12 秒
-        warnings: ['処理時間が長くなる可能性があります', 'API 費用が発生します'],
+        reason: 'The file is large, so precise mode is recommended to retain full information.',
+        estimatedCost: sizeMB * 0.01, // Rough estimate
+        estimatedTime: sizeMB * 12, // Approx. 12 seconds per 1MB
+        warnings: ['Processing time may be long', 'API costs will occur'],
       };
     }
 
-    // 精密モードを推奨
+    // Precise mode recommended
     return {
       recommendedMode: 'precise',
-      reason: '精密モードが利用可能です。テキストと画像の混合コンテンツを保持できます',
+      reason: 'Precise mode is available. Can retain mixed content of text and images.',
       estimatedCost: sizeMB * 0.01,
       estimatedTime: sizeMB * 10,
-      warnings: ['API 費用が発生します'],
+      warnings: ['API costs will occur'],
     };
   }
 
   /**
-   * 処理状態の更新(リアルタイムフィードバック用)
+   * Update processing status (for real-time feedback)
    */
   private updateStatus(
     status: ProcessingStatus['status'],
     progress: number,
     message: string,
   ): void {
-    // ここで WebSocket メッセージを送信したり、データベースを更新したりできます
+    // You can send WebSocket messages or update the database here.
     this.logger.log(`[${status}] ${progress}% - ${message}`);
   }
 }

+ 84 - 47
web/components/views/SettingsView.tsx

@@ -102,6 +102,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     }, [activeTab, currentUser]);
 
     const [kbSettings, setKbSettings] = useState<any>(null);
+    const [localKbSettings, setLocalKbSettings] = useState<any>(null);
+    const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
     const fetchKnowledgeBaseSettings = async () => {
         if (!authToken) return;
         setIsLoading(true);
@@ -112,6 +114,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
             if (res.ok) {
                 const data = await res.json();
                 setKbSettings(data);
+                setLocalKbSettings(data);
             }
         } catch (error) {
             console.error(error);
@@ -120,24 +123,36 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         }
     };
 
-    const handleUpdateKbSettings = async (key: string, value: any) => {
-        if (!authToken) return;
-        const newSettings = { ...kbSettings, [key]: value };
+    const handleUpdateKbSettings = (key: string, value: any) => {
+        setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
+    };
+
+    const handleSaveKbSettings = async () => {
+        if (!authToken || !localKbSettings) return;
+        setIsSavingKbSettings(true);
         try {
             const res = await fetch('/api/v1/admin/settings', {
                 method: 'PUT',
                 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` },
-                body: JSON.stringify(newSettings)
+                body: JSON.stringify(localKbSettings)
             });
             if (res.ok) {
-                setKbSettings(newSettings);
-                showSuccess('Knowledge Base settings updated');
+                setKbSettings(localKbSettings);
+                showSuccess('Knowledge Base settings saved');
+            } else {
+                showError('Failed to save settings');
             }
         } catch (error) {
-            showError('Failed to update settings');
+            showError('Error saving settings');
+        } finally {
+            setIsSavingKbSettings(false);
         }
     };
 
+    const handleCancelKbSettings = () => {
+        setLocalKbSettings(kbSettings);
+    };
+
     const fetchSettingsAndGroups = async () => {
         if (!authToken) return;
         setIsSettingsLoading(true);
@@ -338,8 +353,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         }
     };
 
-    const handleBindAdmin = async (tenantId: string, userId: string) => {
-        if (!userId) return;
+    const handleBindAdmin = async (tenantId: string, userId: string): Promise<boolean> => {
+        if (!userId) return false;
         try {
             const res = await fetch(`/api/v1/tenants/${tenantId}/admin`, {
                 method: 'PUT',
@@ -353,11 +368,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     fetchTenantsData(),
                     fetchUsers()
                 ]);
+                return true;
             } else {
                 showError('Failed to bind admin');
+                return false;
             }
         } catch (e) {
             showError('Error binding admin');
+            return false;
         }
     };
 
@@ -823,17 +841,17 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     <td className="px-6 py-4 text-xs font-mono text-slate-500">{t.domain || '-'}</td>
                                     <td className="px-6 py-4 text-xs">
                                         {(() => {
-                                            const admin = users.find((u: any) =>
-                                                (u.tenantId === t.id || u.tenant_id === t.id || (t.name === 'Default' && (u.tenantId === 'default' || u.tenant_id === 'default'))) &&
-                                                (u.role === 'TENANT_ADMIN' || u.isAdmin)
-                                            );
-                                            if (admin) {
+                                            // Search admin in tenant members - the source of truth for multi-tenancy
+                                            const adminMember = t.members?.find((m: any) => m.role === 'TENANT_ADMIN' || m.role === 'SUPER_ADMIN');
+                                            const adminUser = adminMember?.user;
+
+                                            if (adminUser) {
                                                 return (
                                                     <div className="flex items-center gap-1.5 text-slate-600 font-medium">
                                                         <div className="w-5 h-5 rounded-full bg-indigo-50 flex items-center justify-center">
                                                             <User className="w-3 h-3 text-indigo-500" />
                                                         </div>
-                                                        {admin.username}
+                                                        {adminUser.username}
                                                         <button
                                                             onClick={() => {
                                                                 setBindingTenantId(t.id);
@@ -986,9 +1004,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     .map(u => (
                                         <button
                                             key={u.id}
-                                            onClick={() => {
-                                                handleBindAdmin(bindingTenantId, u.id);
-                                                setBindingTenantId(null);
+                                            onClick={async () => {
+                                                const success = await handleBindAdmin(bindingTenantId, u.id);
+                                                if (success) setBindingTenantId(null);
                                             }}
                                             className="w-full flex items-center justify-between p-4 bg-slate-50/50 hover:bg-indigo-50 border border-slate-200/50 hover:border-indigo-200 rounded-[1.5rem] transition-all group"
                                         >
@@ -1030,8 +1048,27 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     );
     const renderKnowledgeBaseTab = () => (
         <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
-            {kbSettings && (
+            {localKbSettings && (
                 <>
+                    {/* Save/Cancel Bar */}
+                    <div className="flex justify-end gap-3 sticky top-0 z-20 py-4 bg-white/50 backdrop-blur-sm border-b border-slate-100 mb-6">
+                        <button
+                            onClick={handleCancelKbSettings}
+                            disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
+                            className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
+                        >
+                            Cancel
+                        </button>
+                        <button
+                            onClick={handleSaveKbSettings}
+                            disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
+                            className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
+                        >
+                            {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
+                            Save Changes
+                        </button>
+                    </div>
+
                     {/* Model Configuration */}
                     <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
                         <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
@@ -1044,7 +1081,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             <div>
                                 <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Default LLM Model</label>
                                 <select
-                                    value={kbSettings.selectedLLMId || ''}
+                                    value={localKbSettings.selectedLLMId || ''}
                                     onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
                                     className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
                                 >
@@ -1058,7 +1095,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div>
                                     <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Embedding Model</label>
                                     <select
-                                        value={kbSettings.selectedEmbeddingId || ''}
+                                        value={localKbSettings.selectedEmbeddingId || ''}
                                         onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
                                         className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
                                     >
@@ -1071,7 +1108,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div>
                                     <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Rerank Model</label>
                                     <select
-                                        value={kbSettings.selectedRerankId || ''}
+                                        value={localKbSettings.selectedRerankId || ''}
                                         onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
                                         className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
                                     >
@@ -1097,14 +1134,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             <div>
                                 <div className="flex justify-between mb-3 px-1">
                                     <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Temperature</label>
-                                    <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{kbSettings.temperature}</span>
+                                    <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
                                 </div>
                                 <input
                                     type="range"
                                     min="0"
                                     max="1"
                                     step="0.1"
-                                    value={kbSettings.temperature || 0.7}
+                                    value={localKbSettings.temperature || 0.7}
                                     onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
                                     className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                 />
@@ -1117,7 +1154,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Max Response Tokens</label>
                                 <input
                                     type="number"
-                                    value={kbSettings.maxTokens || 2000}
+                                    value={localKbSettings.maxTokens || 2000}
                                     onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
                                     className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
                                 />
@@ -1138,14 +1175,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div>
                                     <div className="flex justify-between mb-3 px-1">
                                         <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Top-K Results</label>
-                                        <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{kbSettings.topK}</span>
+                                        <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
                                     </div>
                                     <input
                                         type="range"
                                         min="1"
                                         max="50"
                                         step="1"
-                                        value={kbSettings.topK || 10}
+                                        value={localKbSettings.topK || 10}
                                         onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
                                         className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                     />
@@ -1153,14 +1190,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div>
                                     <div className="flex justify-between mb-3 px-1">
                                         <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Similarity Threshold</label>
-                                        <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{kbSettings.similarityThreshold}</span>
+                                        <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
                                     </div>
                                     <input
                                         type="range"
                                         min="0"
                                         max="1"
                                         step="0.05"
-                                        value={kbSettings.similarityThreshold || 0.5}
+                                        value={localKbSettings.similarityThreshold || 0.5}
                                         onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
                                         className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                     />
@@ -1174,14 +1211,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         <div className="text-[10px] text-slate-400 font-medium">Combine vector and full-text search results.</div>
                                     </div>
                                     <button
-                                        onClick={() => handleUpdateKbSettings('enableFullTextSearch', !kbSettings.enableFullTextSearch)}
-                                        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
+                                        onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
+                                        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
                                     >
-                                        <span className={`${kbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
+                                        <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
                                     </button>
                                 </div>
 
-                                {kbSettings.enableFullTextSearch && (
+                                {localKbSettings.enableFullTextSearch && (
                                     <motion.div
                                         initial={{ opacity: 0, y: -10 }}
                                         animate={{ opacity: 1, y: 0 }}
@@ -1189,14 +1226,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     >
                                         <div className="flex justify-between mb-2 px-1">
                                             <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Hybrid Weight (Vector vs Text)</label>
-                                            <span className="text-sm font-black text-indigo-600">{kbSettings.hybridVectorWeight || 0.5}</span>
+                                            <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
                                         </div>
                                         <input
                                             type="range"
                                             min="0"
                                             max="1"
                                             step="0.05"
-                                            value={kbSettings.hybridVectorWeight || 0.5}
+                                            value={localKbSettings.hybridVectorWeight || 0.5}
                                             onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
                                             className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                         />
@@ -1214,10 +1251,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             <div className="text-[10px] text-slate-400 font-medium">Rewrites query for better recall.</div>
                                         </div>
                                         <button
-                                            onClick={() => handleUpdateKbSettings('enableQueryExpansion', !kbSettings.enableQueryExpansion)}
-                                            className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
+                                            onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
+                                            className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
                                         >
-                                            <span className={`${kbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
+                                            <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
                                         </button>
                                     </div>
 
@@ -1227,10 +1264,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             <div className="text-[10px] text-slate-400 font-medium">Hypothetical Document Embeddings.</div>
                                         </div>
                                         <button
-                                            onClick={() => handleUpdateKbSettings('enableHyDE', !kbSettings.enableHyDE)}
-                                            className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
+                                            onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
+                                            className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
                                         >
-                                            <span className={`${kbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
+                                            <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
                                         </button>
                                     </div>
                                 </div>
@@ -1241,14 +1278,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         <div className="text-[10px] text-slate-400 font-medium">Re-score search results for higher accuracy.</div>
                                     </div>
                                     <button
-                                        onClick={() => handleUpdateKbSettings('enableRerank', !kbSettings.enableRerank)}
-                                        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
+                                        onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
+                                        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
                                     >
-                                        <span className={`${kbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
+                                        <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
                                     </button>
                                 </div>
 
-                                {kbSettings.enableRerank && (
+                                {localKbSettings.enableRerank && (
                                     <motion.div
                                         initial={{ opacity: 0, y: -10 }}
                                         animate={{ opacity: 1, y: 0 }}
@@ -1256,14 +1293,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     >
                                         <div className="flex justify-between mb-2 px-1">
                                             <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Rerank Similarity Threshold</label>
-                                            <span className="text-sm font-black text-indigo-600">{kbSettings.rerankSimilarityThreshold || 0.5}</span>
+                                            <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
                                         </div>
                                         <input
                                             type="range"
                                             min="0"
                                             max="1"
                                             step="0.05"
-                                            value={kbSettings.rerankSimilarityThreshold || 0.5}
+                                            value={localKbSettings.rerankSimilarityThreshold || 0.5}
                                             onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
                                             className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                         />

+ 4 - 0
web/services/apiClient.ts

@@ -15,6 +15,7 @@ class ApiClient {
   private getAuthHeaders(): Record<string, string> {
     // V2 API key auth (primary)
     const apiKey = localStorage.getItem('kb_api_key');
+    const activeTenantId = localStorage.getItem('kb_active_tenant_id');
     // Legacy JWT token (fallback, kept for compatibility during transition)
     const token = localStorage.getItem('authToken') || localStorage.getItem('token');
     const language = localStorage.getItem('userLanguage') || 'ja';
@@ -22,6 +23,7 @@ class ApiClient {
       'Content-Type': 'application/json',
       'x-user-language': language,
       ...(apiKey && { 'x-api-key': apiKey }),
+      ...(activeTenantId && { 'x-tenant-id': activeTenantId }),
       ...(token && { Authorization: `Bearer ${token}` }),
     };
   }
@@ -92,10 +94,12 @@ class ApiClient {
   // Legacy compatibility method — returns raw Response for streaming and other special cases
   async request(path: string, options: RequestInit = {}): Promise<Response> {
     const apiKey = localStorage.getItem('kb_api_key');
+    const activeTenantId = localStorage.getItem('kb_active_tenant_id');
     const token = localStorage.getItem('authToken');
     const headers = new Headers(options.headers);
 
     if (apiKey) headers.set('x-api-key', apiKey);
+    if (activeTenantId) headers.set('x-tenant-id', activeTenantId);
     if (token) headers.set('Authorization', `Bearer ${token}`);
 
     const language = localStorage.getItem('userLanguage') || 'ja';

+ 73 - 11
web/src/components/layouts/WorkspaceLayout.tsx

@@ -13,8 +13,11 @@ import {
     BookOpen,
     Library,
     UserCircle,
-    HardDrive
+    HardDrive,
+    Building2,
+    ChevronRight
 } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
 import { useAuth } from '../../contexts/AuthContext';
 import { useLanguage } from '../../../contexts/LanguageContext';
 import { cn } from '../../utils/cn';
@@ -53,12 +56,13 @@ const SidebarItem = ({ icon: Icon, label, path, isActive, onClick, badge }: Side
     </button>
 );
 
-export default function WorkspaceLayout() {
-    const { user, logout } = useAuth();
+const WorkspaceLayout: React.FC = () => {
+    const { user, logout, availableTenants, activeTenant, switchTenant } = useAuth();
     const { t } = useLanguage();
     const navigate = useNavigate();
     const location = useLocation();
     const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+    const [showTenantMenu, setShowTenantMenu] = useState(false);
 
     const handleNavClick = (path: string) => {
         navigate(path);
@@ -126,7 +130,7 @@ export default function WorkspaceLayout() {
                             isActive={location.pathname === '/settings'}
                             onClick={handleNavClick}
                         />
-                        {(user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN') && (
+                        {(activeTenant?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN') && (
                             <>
                                 <SidebarItem
                                     icon={UserCircle}
@@ -167,16 +171,16 @@ export default function WorkspaceLayout() {
                 <div className="p-4 border-t border-slate-100/80 mt-auto">
                     <div className="flex items-center gap-3 p-2 mb-3">
                         <div className="flex items-center justify-center w-9 h-9 text-sm font-bold text-white bg-indigo-600 rounded-full shrink-0 shadow-sm">
-                            {user?.email?.[0]?.toUpperCase() || 'A'}
+                            {user?.username?.[0]?.toUpperCase() || 'A'}
                         </div>
                         <div className="flex-1 min-w-0">
                             <div className="flex items-center gap-2 mb-0.5">
-                                <p className="text-sm font-semibold text-slate-900 truncate">{user?.email}</p>
+                                <p className="text-sm font-semibold text-slate-900 truncate">{user?.username}</p>
                                 <span className="px-1.5 py-0.5 text-[9px] font-black bg-blue-50 text-blue-600 rounded-md border border-blue-100 uppercase tracking-tighter">
-                                    {user?.role?.replace('_', ' ') || 'USER'}
+                                    {activeTenant?.role?.replace('_', ' ') || user?.role?.replace('_', ' ') || 'USER'}
                                 </span>
                             </div>
-                            <p className="text-[12px] text-slate-500 truncate">{user?.tenant_name || 'Default'}</p>
+                            <p className="text-[12px] text-slate-500 truncate">{activeTenant?.tenant?.name || 'Default'}</p>
                         </div>
                     </div>
                     <button
@@ -192,7 +196,7 @@ export default function WorkspaceLayout() {
             {/* Main Content Area */}
             <main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
                 {/* Top Header */}
-                <header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md z-10">
+                <header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-[100]">
                     <div className="flex items-center gap-4 flex-1">
                         <button
                             onClick={() => setIsSidebarOpen(!isSidebarOpen)}
@@ -200,7 +204,63 @@ export default function WorkspaceLayout() {
                         >
                             <Menu size={20} />
                         </button>
-                        <div className="relative w-full max-w-2xl mx-auto">
+
+                        {/* Tenant Switcher */}
+                        <div className="relative">
+                            <button
+                                onClick={() => setShowTenantMenu(!showTenantMenu)}
+                                className="flex items-center gap-2 px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl hover:bg-slate-100 transition-all group"
+                            >
+                                <Building2 size={16} className="text-blue-600" />
+                                <span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || 'Select Organization'}</span>
+                                <ChevronRight size={14} className={cn("text-slate-400 transition-transform", showTenantMenu ? "rotate-90" : "")} />
+                            </button>
+
+                            <AnimatePresence>
+                                {showTenantMenu && (
+                                    <>
+                                        <div className="fixed inset-0 z-40" onClick={() => setShowTenantMenu(false)} />
+                                        <motion.div
+                                            initial={{ opacity: 0, y: 10 }}
+                                            animate={{ opacity: 1, y: 0 }}
+                                            exit={{ opacity: 0, y: 10 }}
+                                            className="absolute top-full mt-2 left-0 w-64 bg-white border border-slate-200 rounded-2xl shadow-xl z-50 overflow-hidden"
+                                        >
+                                            <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>
+                                        </motion.div>
+                                    </>
+                                )}
+                            </AnimatePresence>
+                        </div>
+
+                        <div className="relative w-full max-w-lg ml-4">
                             <Search className="absolute text-slate-400 left-4 top-1/2 -translate-y-1/2" size={18} />
                             <input
                                 type="text"
@@ -225,4 +285,6 @@ export default function WorkspaceLayout() {
             </main>
         </div>
     );
-}
+};
+
+export default WorkspaceLayout;

+ 56 - 7
web/src/contexts/AuthContext.tsx

@@ -7,18 +7,31 @@ export interface User {
     username: string;
     role: UserRole;
     tenantId?: string;
-    // Legacy web2 fields kept for UI compatibility
+    // Legacy support
     email?: string;
-    tenant_id?: string;
     tenant_name?: string;
     isNotebookEnabled?: boolean;
 }
 
+export interface TenantMembership {
+    id: string;
+    tenantId: string;
+    role: UserRole;
+    tenant: {
+        id: string;
+        name: string;
+        domain?: string;
+    };
+}
+
 interface AuthContextType {
     user: User | null;
     apiKey: string;
+    availableTenants: TenantMembership[];
+    activeTenant: TenantMembership | null;
     login: (key: string, userData: Partial<User> & { role?: string }) => void;
     logout: () => void;
+    switchTenant: (tenantId: string) => void;
     isLoading: boolean;
 }
 
@@ -27,9 +40,32 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
 export function AuthProvider({ children }: { children: React.ReactNode }) {
     const [user, setUser] = useState<User | null>(null);
     const [apiKey, setApiKey] = useState<string>(localStorage.getItem('kb_api_key') || '');
+    const [availableTenants, setAvailableTenants] = useState<TenantMembership[]>([]);
+    const [activeTenant, setActiveTenant] = useState<TenantMembership | null>(null);
     const [isLoading, setIsLoading] = useState(true);
 
-    // On mount, restore session by validating the stored API key
+    const fetchTenants = async (key: string, currentTenantId?: string) => {
+        try {
+            const res = await fetch('/api/users/tenants', {
+                headers: { 'x-api-key': key }
+            });
+            if (res.ok) {
+                const tenants: TenantMembership[] = await res.json();
+                setAvailableTenants(tenants);
+
+                const savedTenantId = localStorage.getItem('kb_active_tenant_id') || currentTenantId;
+                const active = tenants.find(t => t.tenantId === savedTenantId) || tenants[0] || null;
+                setActiveTenant(active);
+                if (active) {
+                    localStorage.setItem('kb_active_tenant_id', active.tenantId);
+                }
+            }
+        } catch (e) {
+            console.error('Failed to fetch tenants', e);
+        }
+    };
+
+    // On mount, restore session
     useEffect(() => {
         const restoreSession = async () => {
             if (!apiKey) {
@@ -37,7 +73,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                 return;
             }
             try {
-                // /api/users/me is protected by CombinedAuthGuard — accepts x-api-key
                 const res = await fetch('/api/users/me', {
                     headers: { 'x-api-key': apiKey }
                 });
@@ -51,15 +86,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                         tenant_name: data.tenantName,
                         isNotebookEnabled: data.isNotebookEnabled ?? true,
                     });
+                    await fetchTenants(apiKey, data.tenantId);
                 } else {
-                    // API key no longer valid
                     localStorage.removeItem('kb_api_key');
+                    localStorage.removeItem('kb_active_tenant_id');
                     setApiKey('');
                     setUser(null);
                 }
             } catch (e) {
                 console.error('Failed to restore session', e);
-                setIsLoading(false);
             } finally {
                 setIsLoading(false);
             }
@@ -79,16 +114,30 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             tenant_name: (userData as any).tenantName || userData.tenant_name,
             isNotebookEnabled: userData.isNotebookEnabled ?? true,
         });
+        fetchTenants(key, userData.tenantId);
     };
 
     const logout = () => {
         localStorage.removeItem('kb_api_key');
+        localStorage.removeItem('kb_active_tenant_id');
         setApiKey('');
         setUser(null);
+        setAvailableTenants([]);
+        setActiveTenant(null);
+    };
+
+    const switchTenant = (tenantId: string) => {
+        const target = availableTenants.find(t => t.tenantId === tenantId);
+        if (target) {
+            setActiveTenant(target);
+            localStorage.setItem('kb_active_tenant_id', tenantId);
+            // Optionally reload page to reset all states, or just let components re-render
+            window.location.reload();
+        }
     };
 
     return (
-        <AuthContext.Provider value={{ user, apiKey, login, logout, isLoading }}>
+        <AuthContext.Provider value={{ user, apiKey, availableTenants, activeTenant, login, logout, switchTenant, isLoading }}>
             {children}
         </AuthContext.Provider>
     );

+ 33 - 33
web/types.ts

@@ -3,22 +3,22 @@ export interface IndexingConfig {
   chunkSize: number;
   chunkOverlap: number;
   embeddingModelId: string;
-  mode?: 'fast' | 'precise'; // 処理モード:高速/精密
+  mode?: 'fast' | 'precise'; // Processing mode: fast/precise
 }
 
 // Vision Pipeline 相关类型
 export interface VisionAnalysisResult {
-  text: string;              // 抽出されたテキスト内容
-  images: ImageDescription[]; // 画像の説明
-  layout: string;            // レイアウトタイプ
+  text: string;              // Extracted text content
+  images: ImageDescription[]; // Image descriptions
+  layout: string;            // Layout type
   confidence: number;        // 信頼度 (0-1)
-  pageIndex?: number;        // ページ番号
+  pageIndex?: number;        // Page number
 }
 
 export interface ImageDescription {
-  type: string;              // 画像タイプ (グラフ/構成図/フローチャートなど)
-  description: string;       // 詳細な説明
-  position?: number;         // ページ内の位置
+  type: string;              // Image type (graph/diagram/flowchart etc.)
+  description: string;       // Detailed description
+  position?: number;         // Position within the page
 }
 
 export interface PipelineResult {
@@ -29,20 +29,20 @@ export interface PipelineResult {
   processedPages: number;
   failedPages: number;
   results: VisionAnalysisResult[];
-  cost: number;              // コスト(ドル)
-  duration: number;          // 所要時間(秒)
+  cost: number;              // Cost (USD)
+  duration: number;          // Duration (seconds)
   mode: 'precise';
 }
 
 export interface ModeRecommendation {
   recommendedMode: 'precise' | 'fast';
   reason: string;
-  estimatedCost?: number;    // 推定コスト(ドル)
-  estimatedTime?: number;    // 推定時間(秒)
+  estimatedCost?: number;    // Estimated cost (USD)
+  estimatedTime?: number;    // Estimated time (seconds)
   warnings?: string[];
 }
 
-// ナレッジベース拡張機能の型定義
+// Type definitions for knowledge base extensions
 export interface KnowledgeGroup {
   id: string;
   name: string;
@@ -195,40 +195,40 @@ export interface ModelConfig {
   baseUrl?: string; // Base URL for OpenAI compatible API
   apiKey?: string; // API key for the service
   type: ModelType;
-  dimensions?: number; // 埋め込みモデルのベクトル次元
-  supportsVision?: boolean; // 視覚能力をサポートするかどうか
+  dimensions?: number; // Vector dimensions of the embedding model
+  supportsVision?: boolean; // Whether it supports vision capabilities
 
-  // ==================== 追加フィールド ====================
+  // ==================== Additional Fields ====================
   /**
-   * モデルの入力トークン制限
-   * 例: OpenAI=8191, Gemini=2048
+   * Model's input token limit
+   * e.g., OpenAI=8191, Gemini=2048
    */
   maxInputTokens?: number;
 
   /**
-   * バッチ処理制限(1回のリクエストあたりの最大入力数)
-   * 例: OpenAI=2048, Gemini=100
+   * Batch processing limit (maximum number of inputs per request)
+   * e.g., OpenAI=2048, Gemini=100
    */
   maxBatchSize?: number;
 
   /**
-   * ベクトルモデルかどうか(システム設定での識別用)
+   * Whether it is a vector model (for identification in system settings)
    */
   isVectorModel?: boolean;
 
   /**
-   * モデルプロバイダー名(表示用)
-   * 例: "OpenAI", "Google Gemini", "カスタム"
+   * Model provider name (for display)
+   * e.g., "OpenAI", "Google Gemini", "Custom"
    */
   providerName?: string;
 
   /**
-   * 有効かどうか
+   * Whether it is enabled
    */
   isEnabled?: boolean;
 
   /**
-   * このモデルをデフォルトとして使用するかどうか
+   * Whether to use this model as the default
    */
   isDefault?: boolean;
 }
@@ -247,10 +247,10 @@ export interface AppSettings {
   // Retrieval
   enableRerank: boolean;
   topK: number;
-  similarityThreshold: number; // ベクトル検索の類似度しきい値
-  rerankSimilarityThreshold: number; // リランクの類似度しきい値
-  enableFullTextSearch: boolean; // 全文検索を有効にするかどうか
-  hybridVectorWeight: number; // ハイブリッド検索のベクトル重み
+  similarityThreshold: number; // Similarity threshold for vector search
+  rerankSimilarityThreshold: number; // Similarity threshold for reranking
+  enableFullTextSearch: boolean; // Whether to enable full-text search
+  hybridVectorWeight: number; // Vector weight for hybrid search
 
   // Search Enhancement
   enableQueryExpansion: boolean;
@@ -276,10 +276,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
 
   enableRerank: false,
   topK: 4,
-  similarityThreshold: 0.3, // デフォルトのベクトル検索類似度しきい値
-  rerankSimilarityThreshold: 0.5, // デフォルトのリランク類似度しきい値
-  enableFullTextSearch: false, // デフォルトで全文検索をオフにする
-  hybridVectorWeight: 0.7, // ハイブリッド検索のベクトル重み
+  similarityThreshold: 0.3, // Default similarity threshold for vector search
+  rerankSimilarityThreshold: 0.5, // Default similarity threshold for reranking
+  enableFullTextSearch: false, // Turn off full-text search by default
+  hybridVectorWeight: 0.7, // Vector weight for hybrid search
   enableQueryExpansion: false,
   enableHyDE: false,
   language: 'ja',

+ 4 - 4
web/utils/uuid.ts

@@ -1,14 +1,14 @@
 /**
- * UUIDを生成するためのユーティリティ関数です。
- * crypto.randomUUID() のラッパーであり、非セキュアなコンテキスト(HTTPや古いブラウザなど)向けのフォールバック提供します。
+ * Utility function to generate a UUID.
+ * A wrapper for crypto.randomUUID() that provides a fallback for non-secure contexts (HTTP, older browsers, etc.).
  */
 export const generateUUID = (): string => {
-    // crypto.randomUUID が利用可能かチェック (セキュアなコンテキスト HTTPS/localhost でのみ利用可能)
+    // Check if crypto.randomUUID is available (only available in secure contexts like HTTPS/localhost)
     if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
         return crypto.randomUUID();
     }
 
-    // 非セキュアなコンテキストや古いブラウザ向けのフォールバック (RFC4122 v4 互換)
+    // Fallback for non-secure contexts or older browsers (RFC4122 v4 compatible)
     return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
         const r = (Math.random() * 16) | 0;
         const v = c === 'x' ? r : (r & 0x3) | 0x8;