diff --git a/internal/dao/user_tenant.go b/internal/dao/user_tenant.go index 51e790e733..eb97d8c430 100644 --- a/internal/dao/user_tenant.go +++ b/internal/dao/user_tenant.go @@ -17,6 +17,8 @@ package dao import ( + "fmt" + "ragflow/internal/entity" ) @@ -114,6 +116,38 @@ type TenantInfoByUserID struct { UpdateDate string `json:"update_date"` } +// TenantMemberItem holds user details for a tenant member listing. +type TenantMemberItem struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Role string `json:"role"` + Status string `json:"status"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + IsAuthenticated bool `json:"is_authenticated"` + IsActive string `json:"is_active"` + IsAnonymous bool `json:"is_anonymous"` + IsSuperuser bool `json:"is_superuser"` + UpdateDate string `json:"update_date"` +} + +// GetMembersByTenantID returns all non-owner members of a tenant with user details. +// update_date is formatted as "2006-01-02T15:04:05" (no timezone) to match the Python API. +func (dao *UserTenantDAO) GetMembersByTenantID(tenantID string) ([]*TenantMemberItem, error) { + var results []*TenantMemberItem + err := DB.Table("user_tenant"). + Select("user_tenant.id, user_tenant.user_id, user_tenant.role, user_tenant.status, "+ + "user.nickname, user.email, user.avatar, user.is_authenticated, "+ + "user.status AS is_active, user.is_anonymous, user.is_superuser, "+ + "DATE_FORMAT(user.update_date, '%Y-%m-%dT%H:%i:%s') AS update_date"). + Joins("JOIN user ON user_tenant.user_id = user.id"). + Where("user_tenant.tenant_id = ? AND user_tenant.status = ? AND user_tenant.role != ?", + tenantID, "1", "owner"). + Scan(&results).Error + return results, err +} + // GetTenantsByUserID get tenants by user ID with user details func (dao *UserTenantDAO) GetTenantsByUserID(userID string) ([]*TenantInfoByUserID, error) { var results []*TenantInfoByUserID @@ -143,3 +177,25 @@ func (dao *UserTenantDAO) GetByUserIDAll(userID string) ([]*entity.UserTenant, e err := DB.Where("user_id = ?", userID).Find(&relations).Error return relations, err } + +// DeleteByUserAndTenant hard-deletes the join record for a specific user+tenant pair. +func (dao *UserTenantDAO) DeleteByUserAndTenant(userID, tenantID string) error { + return DB.Unscoped(). + Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Delete(&entity.UserTenant{}).Error +} + +// UpdateRoleByUserAndTenant updates the role for a specific user+tenant pair. +// Returns an error if no matching row was found. +func (dao *UserTenantDAO) UpdateRoleByUserAndTenant(userID, tenantID, role string) error { + result := DB.Model(&entity.UserTenant{}). + Where("user_id = ? AND tenant_id = ? AND status = ?", userID, tenantID, "1"). + Update("role", role) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no active membership found for user %s in tenant %s", userID, tenantID) + } + return nil +} diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 4505cb7bca..9e4c6ec5ba 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -542,3 +542,129 @@ func (h *TenantHandler) InsertMetadataFromFile(c *gin.Context) { "message": "success", }) } + +// ListTenantMembers lists all non-owner members of a tenant. +// @Summary List tenant members +// @Tags tenants +// @Produce json +// @Param tenant_id path string true "Tenant ID" +// @Router /api/v1/tenants/{tenant_id}/users [get] +func (h *TenantHandler) ListTenantMembers(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + tenantID := c.Param("tenant_id") + if tenantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"}) + return + } + + members, code, err := h.tenantService.ListMembers(user.ID, tenantID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": members, "message": "success"}) +} + +// AddTenantMember invites a user (by email) to the tenant. +// @Summary Invite a user to a tenant +// @Tags tenants +// @Accept json +// @Produce json +// @Param tenant_id path string true "Tenant ID" +// @Param request body service.AddMemberRequest true "Invite request" +// @Router /api/v1/tenants/{tenant_id}/users [post] +func (h *TenantHandler) AddTenantMember(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + tenantID := c.Param("tenant_id") + if tenantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"}) + return + } + + var req service.AddMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "invalid request body: " + err.Error()}) + return + } + + resp, code, err := h.tenantService.AddMember(user.ID, tenantID, &req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": resp, "message": "success"}) +} + +// RemoveTenantMember removes a user from the tenant. +// @Summary Remove a user from a tenant +// @Tags tenants +// @Accept json +// @Produce json +// @Param tenant_id path string true "Tenant ID" +// @Param request body object true "Remove member request" SchemaExample({"user_id":"string"}) +// @Router /api/v1/tenants/{tenant_id}/users [delete] +func (h *TenantHandler) RemoveTenantMember(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + tenantID := c.Param("tenant_id") + if tenantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"}) + return + } + + var body struct { + UserID string `json:"user_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "user_id is required"}) + return + } + + code, err := h.tenantService.RemoveMember(user.ID, tenantID, body.UserID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": true, "message": "success"}) +} + +// AcceptTenantInvite accepts a pending team invitation, transitioning role invite → normal. +// @Summary Accept tenant invitation +// @Tags tenants +// @Produce json +// @Param tenant_id path string true "Tenant ID" +// @Router /api/v1/tenants/{tenant_id} [patch] +func (h *TenantHandler) AcceptTenantInvite(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + tenantID := c.Param("tenant_id") + if tenantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"}) + return + } + + code, err := h.tenantService.AcceptInvite(user.ID, tenantID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": true, "message": "success"}) +} diff --git a/internal/router/router.go b/internal/router/router.go index e1f2e48f1f..e5b4910688 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -160,6 +160,10 @@ func (r *Router) Setup(engine *gin.Engine) { tenants := v1.Group("/tenants") { tenants.GET("", r.tenantHandler.TenantList) + tenants.PATCH("/:tenant_id", r.tenantHandler.AcceptTenantInvite) + tenants.GET("/:tenant_id/users", r.tenantHandler.ListTenantMembers) + tenants.POST("/:tenant_id/users", r.tenantHandler.AddTenantMember) + tenants.DELETE("/:tenant_id/users", r.tenantHandler.RemoveTenantMember) } v1.GET("/tenant/list", r.tenantHandler.TenantList) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 7ff19c5f37..c7bb26bd2a 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -30,6 +30,7 @@ import ( type TenantService struct { tenantDAO *dao.TenantDAO userTenantDAO *dao.UserTenantDAO + userDAO *dao.UserDAO modelProviderDAO *dao.TenantModelProviderDAO modelInstanceDAO *dao.TenantModelInstanceDAO modelDAO *dao.TenantModelDAO @@ -44,6 +45,7 @@ func NewTenantService() *TenantService { return &TenantService{ tenantDAO: dao.NewTenantDAO(), userTenantDAO: dao.NewUserTenantDAO(), + userDAO: dao.NewUserDAO(), modelProviderDAO: dao.NewTenantModelProviderDAO(), modelInstanceDAO: dao.NewTenantModelInstanceDAO(), modelDAO: dao.NewTenantModelDAO(), @@ -654,3 +656,157 @@ func (s *TenantService) SetTenantDefaultModels(userID, modelProvider, modelInsta return nil } + +// Tenant member role constants. +const ( + TenantRoleOwner = "owner" + TenantRoleNormal = "normal" + TenantRoleInvite = "invite" + TenantRoleAdmin = "admin" +) + +// TenantMemberResponse is one entry in the member list response. +type TenantMemberResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Role string `json:"role"` + Status string `json:"status"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + IsAuthenticated bool `json:"is_authenticated"` + IsActive string `json:"is_active"` + IsAnonymous bool `json:"is_anonymous"` + IsSuperuser bool `json:"is_superuser"` + UpdateDate string `json:"update_date"` + DeltaSeconds float64 `json:"delta_seconds"` +} + +// ListMembers returns all non-owner members of tenantID. +// Only the tenant owner (userID == tenantID) may call this. +func (s *TenantService) ListMembers(userID, tenantID string) ([]*TenantMemberResponse, common.ErrorCode, error) { + if userID != tenantID { + return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization") + } + rows, err := s.userTenantDAO.GetMembersByTenantID(tenantID) + if err != nil { + return nil, common.CodeServerError, err + } + result := make([]*TenantMemberResponse, 0, len(rows)) + for _, r := range rows { + delta, _ := common.DeltaSeconds(r.UpdateDate) + result = append(result, &TenantMemberResponse{ + ID: r.ID, + UserID: r.UserID, + Role: r.Role, + Status: r.Status, + Nickname: r.Nickname, + Email: r.Email, + Avatar: r.Avatar, + IsAuthenticated: r.IsAuthenticated, + IsActive: r.IsActive, + IsAnonymous: r.IsAnonymous, + IsSuperuser: r.IsSuperuser, + UpdateDate: r.UpdateDate, + DeltaSeconds: delta, + }) + } + return result, common.CodeSuccess, nil +} + +// AddMemberRequest holds the invite payload. +type AddMemberRequest struct { + Email string `json:"email"` +} + +// AddMemberResponse holds the new member's public data. +type AddMemberResponse struct { + ID string `json:"id"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Nickname string `json:"nickname"` +} + +// AddMember invites a user (by email) to the tenant. +// Only the tenant owner (userID == tenantID) may call this. +func (s *TenantService) AddMember(userID, tenantID string, req *AddMemberRequest) (*AddMemberResponse, common.ErrorCode, error) { + if userID != tenantID { + return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization") + } + if req.Email == "" { + return nil, common.CodeArgumentError, fmt.Errorf("email is required") + } + + invitee, err := s.userDAO.GetByEmail(req.Email) + if err != nil { + return nil, common.CodeDataError, fmt.Errorf("user not found") + } + + // Reject if already a member or has a pending invitation. + existing, _ := s.userTenantDAO.FilterByUserIDAndTenantID(invitee.ID, tenantID) + if existing != nil { + switch existing.Role { + case TenantRoleOwner: + return nil, common.CodeDataError, fmt.Errorf("user is already the tenant owner") + case TenantRoleNormal, TenantRoleAdmin: + return nil, common.CodeDataError, fmt.Errorf("user is already a member") + case TenantRoleInvite: + return nil, common.CodeDataError, fmt.Errorf("user already has a pending invitation") + } + } + + status := "1" + ut := &entity.UserTenant{ + ID: common.GenerateUUID(), + UserID: invitee.ID, + TenantID: tenantID, + Role: TenantRoleInvite, + InvitedBy: userID, + Status: &status, + } + if err := s.userTenantDAO.Create(ut); err != nil { + return nil, common.CodeServerError, fmt.Errorf("failed to create invitation: %w", err) + } + + avatar := "" + if invitee.Avatar != nil { + avatar = *invitee.Avatar + } + return &AddMemberResponse{ + ID: invitee.ID, + Avatar: avatar, + Email: invitee.Email, + Nickname: invitee.Nickname, + }, common.CodeSuccess, nil +} + +// RemoveMember removes a user from the tenant. +// Either the owner (userID == tenantID) or the member themselves (userID == targetUserID) may call this. +// The tenant owner (targetUserID == tenantID) cannot be removed. +func (s *TenantService) RemoveMember(userID, tenantID, targetUserID string) (common.ErrorCode, error) { + if userID != tenantID && userID != targetUserID { + return common.CodeAuthenticationError, fmt.Errorf("no authorization") + } + if targetUserID == tenantID { + return common.CodeArgumentError, fmt.Errorf("cannot remove the tenant owner") + } + if err := s.userTenantDAO.DeleteByUserAndTenant(targetUserID, tenantID); err != nil { + return common.CodeServerError, fmt.Errorf("failed to remove member: %w", err) + } + return common.CodeSuccess, nil +} + +// AcceptInvite transitions the calling user's role from "invite" → "normal" for the given tenant. +func (s *TenantService) AcceptInvite(userID, tenantID string) (common.ErrorCode, error) { + existing, err := s.userTenantDAO.FilterByUserIDAndTenantID(userID, tenantID) + if err != nil || existing == nil { + return common.CodeDataError, fmt.Errorf("no pending invitation found") + } + if existing.Role != TenantRoleInvite { + return common.CodeArgumentError, fmt.Errorf("no pending invitation to accept") + } + if err := s.userTenantDAO.UpdateRoleByUserAndTenant(userID, tenantID, TenantRoleNormal); err != nil { + return common.CodeServerError, fmt.Errorf("failed to accept invitation: %w", err) + } + return common.CodeSuccess, nil +} diff --git a/internal/service/tenant_test.go b/internal/service/tenant_test.go new file mode 100644 index 0000000000..c28095b3a1 --- /dev/null +++ b/internal/service/tenant_test.go @@ -0,0 +1,112 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package service + +import ( + "testing" + + "ragflow/internal/common" +) + +// TestListMembersAuthCheck verifies that a non-owner (userID != tenantID) gets +// CodeAuthenticationError without hitting the database. +func TestListMembersAuthCheck(t *testing.T) { + s := &TenantService{} + _, code, err := s.ListMembers("user-abc", "tenant-xyz") + if err == nil { + t.Fatal("expected error for non-owner, got nil") + } + if code != common.CodeAuthenticationError { + t.Errorf("expected CodeAuthenticationError, got %v", code) + } +} + +// TestAddMemberAuthCheck verifies that a non-owner gets CodeAuthenticationError. +func TestAddMemberAuthCheck(t *testing.T) { + s := &TenantService{} + _, code, err := s.AddMember("user-abc", "tenant-xyz", &AddMemberRequest{Email: "a@b.com"}) + if err == nil { + t.Fatal("expected error for non-owner, got nil") + } + if code != common.CodeAuthenticationError { + t.Errorf("expected CodeAuthenticationError, got %v", code) + } +} + +// TestAddMemberEmailRequired verifies the email validation runs after the auth check. +func TestAddMemberEmailRequired(t *testing.T) { + // When userID == tenantID (owner) but no email, expect CodeArgumentError. + s := &TenantService{} + _, code, err := s.AddMember("owner-id", "owner-id", &AddMemberRequest{Email: ""}) + if err == nil { + t.Fatal("expected error for empty email, got nil") + } + if code != common.CodeArgumentError { + t.Errorf("expected CodeArgumentError, got %v", code) + } +} + +// TestRemoveMemberAuthCheck verifies that an unrelated user gets CodeAuthenticationError. +func TestRemoveMemberAuthCheck(t *testing.T) { + s := &TenantService{} + code, err := s.RemoveMember("user-abc", "tenant-xyz", "user-def") + if err == nil { + t.Fatal("expected error, got nil") + } + if code != common.CodeAuthenticationError { + t.Errorf("expected CodeAuthenticationError, got %v", code) + } +} + +// TestRemoveMemberSelfAllowed verifies that a user removing themselves passes the auth check. +// (It will fail at the DB layer in unit tests, but the auth check must pass first.) +func TestRemoveMemberSelfAllowed(t *testing.T) { + s := &TenantService{} + // userID == targetUserID: auth check should pass, expect DB error (nil userTenantDAO). + code, err := s.RemoveMember("user-abc", "tenant-xyz", "user-abc") + if code == common.CodeAuthenticationError { + t.Errorf("self-removal should pass auth check, got CodeAuthenticationError: %v", err) + } +} + +// TestAcceptInviteAuthCheck verifies that AcceptInvite fails when no membership exists (nil DAO). +func TestAcceptInviteAuthCheck(t *testing.T) { + s := &TenantService{} + // nil userTenantDAO: FilterByUserIDAndTenantID will panic/err, so we expect CodeDataError. + code, err := s.AcceptInvite("user-abc", "tenant-xyz") + if err == nil { + t.Fatal("expected error when no membership exists, got nil") + } + if code != common.CodeDataError { + t.Errorf("expected CodeDataError, got %v", code) + } +} + +// TestTenantRoleConstants verifies the role string values match the Python enums. +func TestTenantRoleConstants(t *testing.T) { + cases := map[string]string{ + TenantRoleOwner: "owner", + TenantRoleNormal: "normal", + TenantRoleInvite: "invite", + TenantRoleAdmin: "admin", + } + for got, want := range cases { + if got != want { + t.Errorf("role constant = %q, want %q", got, want) + } + } +}