From f94df2b3a70f27fc307237b22043db313ca96515 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 02:48:35 -0700 Subject: [PATCH 01/21] feat(auth): add scope-aware login and oauth tenant-join flow --- .../internal/handler/public/auth/handler.go | 4 + .../handler/public/oauth/server_handler.go | 98 ++++++++++--- .../handler/public/oauth/server_service.go | 96 +++++++++++-- .../oauth/server_tenant_authorization_test.go | 114 +++++++++++++++ .../internal/service/auth/dto.go | 1 + .../internal/service/auth/jwt.go | 18 ++- .../internal/service/auth/service.go | 87 +++++++++--- .../internal/service/auth/service_test.go | 87 ++++++++++++ .../internal/service/verification/service.go | 46 +++++- .../src/features/auth/OAuthConsent.tsx | 132 +++++++++++++++++- 10 files changed, 608 insertions(+), 75 deletions(-) create mode 100644 basaltpass-backend/internal/handler/public/oauth/server_tenant_authorization_test.go diff --git a/basaltpass-backend/internal/handler/public/auth/handler.go b/basaltpass-backend/internal/handler/public/auth/handler.go index 2bb0248e..0d0800eb 100644 --- a/basaltpass-backend/internal/handler/public/auth/handler.go +++ b/basaltpass-backend/internal/handler/public/auth/handler.go @@ -92,6 +92,7 @@ func LoginHandler(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } + req.Scope = normalizeScope(c.Get("X-Auth-Scope")) // Hydrate legacy fields for backward compatibility with old clients. hydrateLegacyLoginFields(c, &req) @@ -108,6 +109,9 @@ func LoginHandler(c *fiber.Ctx) error { if errors.Is(err, auth2.ErrPlatformAdminOnly) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } + if errors.Is(err, auth2.ErrTenantAccountOnly) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + } if errors.Is(err, auth2.ErrTenantLoginDisabled) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } diff --git a/basaltpass-backend/internal/handler/public/oauth/server_handler.go b/basaltpass-backend/internal/handler/public/oauth/server_handler.go index df3e3578..268e8547 100644 --- a/basaltpass-backend/internal/handler/public/oauth/server_handler.go +++ b/basaltpass-backend/internal/handler/public/oauth/server_handler.go @@ -57,34 +57,37 @@ func AuthorizeHandler(c *fiber.Ctx) error { // 用户已登录,验证用户是否属于该租户 uid := userID.(uint) - if err := oauthServerService.ValidateUserTenant(uid, client); err != nil { + decision, err := oauthServerService.EvaluateUserTenantAuthorization(uid, client) + if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "tenant_mismatch", - "error_description": "User does not belong to the tenant of this application", + "error": "tenant_context_error", + "error_description": err.Error(), }) } // 用户已登录且属于正确的租户,重定向到前端托管的授权同意页面 - alreadyAuthorized, err := oauthServerService.HasAppUserAuthorization(client.AppID, uid) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "server_error", - "error_description": "Failed to check existing app authorization", - }) - } - - // Skip consent when the user has already authorized this app. - if alreadyAuthorized { - code, err := oauthServerService.GenerateAuthorizationCode(uid, req, client) + if decision.Allowed { + alreadyAuthorized, err := oauthServerService.HasAppUserAuthorization(client.AppID, uid) if err != nil { - return redirectWithError(c, req.RedirectURI, "server_error", req.State) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "server_error", + "error_description": "Failed to check existing app authorization", + }) } - aduit.LogAudit(uid, "OAuth2授权(免再次确认)", "oauth_client", req.ClientID, c.IP(), c.Get("User-Agent")) - return redirectWithCode(c, req.RedirectURI, code, req.State) + // Skip consent when the user has already authorized this app. + if alreadyAuthorized { + code, err := oauthServerService.GenerateAuthorizationCode(uid, req, client) + if err != nil { + return redirectWithError(c, req.RedirectURI, "server_error", req.State) + } + + aduit.LogAudit(uid, "OAuth2授权(免再次确认)", "oauth_client", req.ClientID, c.IP(), c.Get("User-Agent")) + return redirectWithCode(c, req.RedirectURI, code, req.State) + } } - consentURL := buildConsentURL(req, client) + consentURL := buildConsentURL(req, client, decision) return c.Redirect(consentURL, http.StatusFound) } @@ -150,7 +153,7 @@ func parseUserIDFromJWT(tokenStr string) (uint, bool) { return 0, false } -func buildConsentURL(req *AuthorizeRequest, client *model.OAuthClient) string { +func buildConsentURL(req *AuthorizeRequest, client *model.OAuthClient, decision *UserTenantAuthorizationDecision) string { uiBaseURL := strings.TrimRight(config.Get().UI.BaseURL, "/") consentPath := "/oauth-consent" base := consentPath @@ -173,6 +176,20 @@ func buildConsentURL(req *AuthorizeRequest, client *model.OAuthClient) string { if req.CodeChallengeMethod != "" { q.Set("code_challenge_method", req.CodeChallengeMethod) } + if client != nil { + appTenantID := oauthServerService.resolveClientTenantID(client) + if appTenantID > 0 { + q.Set("app_tenant_id", strconv.FormatUint(uint64(appTenantID), 10)) + } + } + if decision != nil { + if decision.JoinRequired { + q.Set("current_user_join_required", "true") + } + if decision.TenantID > 0 { + q.Set("decision_tenant_id", strconv.FormatUint(uint64(decision.TenantID), 10)) + } + } if client != nil { // Prefer app display fields when available. if strings.TrimSpace(client.App.Name) != "" { @@ -218,6 +235,8 @@ func ConsentHandler(c *fiber.Ctx) error { scope := c.FormValue("scope") codeChallenge := c.FormValue("code_challenge") codeChallengeMethod := c.FormValue("code_challenge_method") + selectedAccessToken := strings.TrimSpace(c.FormValue("selected_access_token")) + joinTenant := strings.EqualFold(strings.TrimSpace(c.FormValue("join_tenant")), "true") || c.FormValue("join_tenant") == "1" action := c.FormValue("action") // "allow" 或 "deny" if action != "allow" { @@ -225,6 +244,17 @@ func ConsentHandler(c *fiber.Ctx) error { return redirectWithErrorIfAllowed(c, clientID, redirectURI, "access_denied", state) } + if selectedAccessToken != "" { + selectedUserID, ok := parseUserIDFromJWT(selectedAccessToken) + if !ok || selectedUserID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid_selected_session", + "error_description": "Selected account session is invalid or expired", + }) + } + userID = selectedUserID + } + // 构建授权请求 req := &AuthorizeRequest{ ClientID: clientID, @@ -242,14 +272,36 @@ func ConsentHandler(c *fiber.Ctx) error { return redirectWithErrorIfAllowed(c, clientID, redirectURI, err.Error(), state) } - // 验证用户是否属于该租户 - if err := oauthServerService.ValidateUserTenant(userID, client); err != nil { + decision, err := oauthServerService.EvaluateUserTenantAuthorization(userID, client) + if err != nil { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "tenant_mismatch", - "error_description": "User does not belong to the tenant of this application", + "error": "tenant_context_error", + "error_description": err.Error(), }) } + if !decision.Allowed { + if decision.JoinRequired { + if !joinTenant { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "join_confirmation_required", + "error_description": "Global account must confirm tenant join before authorization", + }) + } + if err := oauthServerService.EnsureUserTenantIdentity(userID, decision.TenantID, model.TenantRoleMember); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "identity_join_failed", + "error_description": "Failed to join tenant identity", + }) + } + } else { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "tenant_mismatch", + "error_description": "User does not belong to the tenant of this application", + }) + } + } + // 生成授权码 code, err := oauthServerService.GenerateAuthorizationCode(userID, req, client) if err != nil { diff --git a/basaltpass-backend/internal/handler/public/oauth/server_service.go b/basaltpass-backend/internal/handler/public/oauth/server_service.go index 2da97463..6de31878 100644 --- a/basaltpass-backend/internal/handler/public/oauth/server_service.go +++ b/basaltpass-backend/internal/handler/public/oauth/server_service.go @@ -23,6 +23,13 @@ type OAuthServerService struct { db *gorm.DB } +type UserTenantAuthorizationDecision struct { + Allowed bool + JoinRequired bool + TenantID uint + Reason string +} + // NewOAuthServerService 创建新的OAuth2服务器服务 func NewOAuthServerService() *OAuthServerService { return &OAuthServerService{ @@ -597,29 +604,90 @@ func (s *OAuthServerService) RevokeToken(token string) error { return nil } -// ValidateUserTenant 验证用户是否属于应用所在的租户 -func (s *OAuthServerService) ValidateUserTenant(userID uint, client *model.OAuthClient) error { - // 获取用户信息 - var user model.User - if err := s.db.Select("id", "tenant_id", "is_system_admin").First(&user, userID).Error; err != nil { - return errors.New("user_not_found") +func (s *OAuthServerService) EnsureUserTenantIdentity(userID, tenantID uint, role model.TenantRole) error { + if userID == 0 || tenantID == 0 { + return errors.New("invalid_identity_context") + } + if role == "" { + role = model.TenantRoleMember } - // 系统管理员可以访问所有租户的应用 - if user.IsSystemAdmin != nil && *user.IsSystemAdmin { + var existing model.TenantUser + err := s.db.Where("user_id = ? AND tenant_id = ?", userID, tenantID).First(&existing).Error + if err == nil { return nil } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + return s.db.Create(&model.TenantUser{ + UserID: userID, + TenantID: tenantID, + Role: role, + }).Error +} + +func (s *OAuthServerService) EvaluateUserTenantAuthorization(userID uint, client *model.OAuthClient) (*UserTenantAuthorizationDecision, error) { + var user model.User + if err := s.db.Select("id", "tenant_id", "is_system_admin").First(&user, userID).Error; err != nil { + return nil, errors.New("user_not_found") + } - // 获取应用所属租户ID(支持历史脏数据的兜底恢复) tenantID := s.resolveClientTenantID(client) if tenantID == 0 { - return errors.New("app_tenant_not_found") + return nil, errors.New("app_tenant_not_found") } - // 验证用户的tenant_id是否与应用的tenant_id匹配 - if user.TenantID != tenantID { - return errors.New("tenant_mismatch") + if user.IsSystemAdmin != nil && *user.IsSystemAdmin { + return &UserTenantAuthorizationDecision{Allowed: true, TenantID: tenantID}, nil } - return nil + if user.TenantID == tenantID { + return &UserTenantAuthorizationDecision{Allowed: true, TenantID: tenantID}, nil + } + + if user.TenantID == 0 { + var membershipCount int64 + if err := s.db.Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Count(&membershipCount).Error; err != nil { + return nil, err + } + if membershipCount > 0 { + return &UserTenantAuthorizationDecision{Allowed: true, TenantID: tenantID}, nil + } + + return &UserTenantAuthorizationDecision{ + Allowed: false, + JoinRequired: true, + TenantID: tenantID, + Reason: "join_required", + }, nil + } + + return &UserTenantAuthorizationDecision{ + Allowed: false, + JoinRequired: false, + TenantID: tenantID, + Reason: "tenant_mismatch", + }, nil +} + +// ValidateUserTenant 验证用户是否属于应用所在的租户 +func (s *OAuthServerService) ValidateUserTenant(userID uint, client *model.OAuthClient) error { + decision, err := s.EvaluateUserTenantAuthorization(userID, client) + if err != nil { + return err + } + if decision.Allowed { + return nil + } + if decision.JoinRequired { + return errors.New("join_required") + } + if decision.Reason != "" { + return errors.New(decision.Reason) + } + return errors.New("tenant_mismatch") } diff --git a/basaltpass-backend/internal/handler/public/oauth/server_tenant_authorization_test.go b/basaltpass-backend/internal/handler/public/oauth/server_tenant_authorization_test.go new file mode 100644 index 00000000..9324f31c --- /dev/null +++ b/basaltpass-backend/internal/handler/public/oauth/server_tenant_authorization_test.go @@ -0,0 +1,114 @@ +package oauth + +import ( + "testing" + "time" + + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + + "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" +) + +func setupOAuthTenantTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.App{}, &model.OAuthClient{}); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + common.SetDBForTest(db) + return db +} + +func TestEvaluateUserTenantAuthorizationJoinThenAllow(t *testing.T) { + db := setupOAuthTenantTestDB(t) + + tenant := model.Tenant{Name: "Tenant-A", Code: "tenant-a", Status: model.TenantStatusActive} + if err := db.Create(&tenant).Error; err != nil { + t.Fatalf("create tenant failed: %v", err) + } + + globalUser := model.User{TenantID: 0, Email: "global-join@example.com", PasswordHash: "x"} + if err := db.Create(&globalUser).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + app := model.App{TenantID: tenant.ID, Name: "Join App", Status: model.AppStatusActive} + if err := db.Create(&app).Error; err != nil { + t.Fatalf("create app failed: %v", err) + } + + client := model.OAuthClient{ + AppID: app.ID, + ClientID: "client-join", + ClientSecret: "secret", + RedirectURIs: "https://example.com/callback", + IsActive: true, + CreatedBy: globalUser.ID, + } + if err := db.Create(&client).Error; err != nil { + t.Fatalf("create client failed: %v", err) + } + + svc := NewOAuthServerService() + + decision, err := svc.EvaluateUserTenantAuthorization(globalUser.ID, &client) + if err != nil { + t.Fatalf("evaluate authorization failed: %v", err) + } + if decision.Allowed { + t.Fatalf("expected not allowed before join") + } + if !decision.JoinRequired { + t.Fatalf("expected join_required=true for global user") + } + if decision.TenantID != tenant.ID { + t.Fatalf("expected tenant id %d, got %d", tenant.ID, decision.TenantID) + } + + if err := svc.EnsureUserTenantIdentity(globalUser.ID, tenant.ID, model.TenantRoleMember); err != nil { + t.Fatalf("ensure tenant identity failed: %v", err) + } + + decision, err = svc.EvaluateUserTenantAuthorization(globalUser.ID, &client) + if err != nil { + t.Fatalf("re-evaluate authorization failed: %v", err) + } + if !decision.Allowed { + t.Fatalf("expected allowed after join") + } + if decision.JoinRequired { + t.Fatalf("expected join_required=false after membership creation") + } +} + +func TestParseUserIDFromJWTForSelectedAccessToken(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": float64(42), + "exp": time.Now().Add(5 * time.Minute).Unix(), + }) + signed, err := token.SignedString(common.MustJWTSecret()) + if err != nil { + t.Fatalf("sign token failed: %v", err) + } + + uid, ok := parseUserIDFromJWT(signed) + if !ok { + t.Fatalf("expected parse success for valid selected_access_token") + } + if uid != 42 { + t.Fatalf("expected user id 42, got %d", uid) + } + + if _, ok := parseUserIDFromJWT("not-a-token"); ok { + t.Fatalf("expected parse failure for invalid token") + } +} diff --git a/basaltpass-backend/internal/service/auth/dto.go b/basaltpass-backend/internal/service/auth/dto.go index 93874655..e3d430f3 100644 --- a/basaltpass-backend/internal/service/auth/dto.go +++ b/basaltpass-backend/internal/service/auth/dto.go @@ -13,6 +13,7 @@ type LoginRequest struct { EmailOrPhone string `json:"identifier"` Password string `json:"password"` TenantID uint `json:"tenant_id"` // 租户ID,用于识别用户属于哪个租户 + Scope string `json:"-"` } // Verify2FARequest defines input for 2FA verification. diff --git a/basaltpass-backend/internal/service/auth/jwt.go b/basaltpass-backend/internal/service/auth/jwt.go index 04e01035..71a405b7 100644 --- a/basaltpass-backend/internal/service/auth/jwt.go +++ b/basaltpass-backend/internal/service/auth/jwt.go @@ -129,16 +129,14 @@ func resolveTokenTenantID(userID uint, claimedTenantID uint, scope string) (uint return claimedTenantID, nil } - if scope == ConsoleScopeTenant { - var membershipCount int64 - if err := common.DB().Model(&model.TenantUser{}). - Where("user_id = ? AND tenant_id = ?", userID, claimedTenantID). - Count(&membershipCount).Error; err != nil { - return 0, err - } - if membershipCount > 0 { - return claimedTenantID, nil - } + var membershipCount int64 + if err := common.DB().Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ?", userID, claimedTenantID). + Count(&membershipCount).Error; err != nil { + return 0, err + } + if membershipCount > 0 { + return claimedTenantID, nil } } diff --git a/basaltpass-backend/internal/service/auth/service.go b/basaltpass-backend/internal/service/auth/service.go index 39512b0a..24def055 100644 --- a/basaltpass-backend/internal/service/auth/service.go +++ b/basaltpass-backend/internal/service/auth/service.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "basaltpass-backend/internal/common" @@ -26,6 +27,7 @@ var ( ErrMissingCredentials = errors.New("identifier and password required") ErrInvalidCredentials = errors.New("invalid email or password") ErrPlatformAdminOnly = errors.New("only administrators can login to platform") + ErrTenantAccountOnly = errors.New("tenant account must login via tenant portal") ErrTenantLoginDisabled = errors.New("tenant login is disabled") ErrServiceUnavailable = errors.New("authentication service temporarily unavailable") ) @@ -74,21 +76,48 @@ func (s Service) Register(req RegisterRequest) (*model.User, error) { } isFirstUser := userCount == 0 - // 检查用户是否已存在(同一个租户下的邮箱/手机号) - // 注意:admin用户(tenant_id=0)可以与普通用户使用相同的邮箱/手机号 + // 全局注册(tenant_id=0)要求邮箱/手机号全局唯一; + // 租户注册(tenant_id>0)仅要求同租户唯一,同时不能与全局账号冲突。 db := common.DB() - var existingUser model.User + normalizedEmail := strings.ToLower(strings.TrimSpace(req.Email)) - // 构建检查条件 - checkQuery := db.Where("tenant_id = ?", req.TenantID) - if req.Email != "" { - checkQuery = checkQuery.Where("email = ?", req.Email) - } else if normalizedPhone != "" { - checkQuery = checkQuery.Where("phone = ?", normalizedPhone) - } + if req.TenantID == 0 { + var conflict int64 + query := db.Model(&model.User{}) + if normalizedEmail != "" { + query = query.Where("email = ?", normalizedEmail) + } + if normalizedPhone != "" { + if normalizedEmail != "" { + query = query.Or("phone = ?", normalizedPhone) + } else { + query = query.Where("phone = ?", normalizedPhone) + } + } + if err := query.Count(&conflict).Error; err != nil { + return nil, err + } + if conflict > 0 { + return nil, errors.New("user already exists") + } + } else { + if normalizedEmail != "" { + var sameTenant int64 + if err := db.Model(&model.User{}).Where("email = ? AND tenant_id = ?", normalizedEmail, req.TenantID).Count(&sameTenant).Error; err != nil { + return nil, err + } + if sameTenant > 0 { + return nil, errors.New("user already exists in this tenant") + } - if err := checkQuery.First(&existingUser).Error; err == nil { - return nil, errors.New("user already exists in this tenant") + var globalConflict int64 + if err := db.Model(&model.User{}).Where("email = ? AND tenant_id = 0", normalizedEmail).Count(&globalConflict).Error; err != nil { + return nil, err + } + if globalConflict > 0 { + return nil, errors.New("email already registered as a global account") + } + } } // 开始事务 @@ -100,7 +129,7 @@ func (s Service) Register(req RegisterRequest) (*model.User, error) { }() user := &model.User{ - Email: req.Email, + Email: normalizedEmail, Phone: normalizedPhone, PasswordHash: string(hash), Nickname: "New User", @@ -156,6 +185,10 @@ func (s Service) LoginV2(req LoginRequest) (LoginResult, error) { if req.EmailOrPhone == "" || req.Password == "" { return LoginResult{}, ErrMissingCredentials } + if req.Scope == "" { + req.Scope = ConsoleScopeUser + } + identifier := strings.TrimSpace(req.EmailOrPhone) if req.TenantID > 0 { allowed, err := tenantservice.IsTenantLoginAllowed(req.TenantID) @@ -177,18 +210,24 @@ func (s Service) LoginV2(req LoginRequest) (LoginResult, error) { db := common.DB().WithContext(ctx) // 构建查询条件:email/phone + tenant_id - // 规则: - // 1. tenant_id = 0 表示平台登录,只允许 is_system_admin = true 的账号登录 - // 2. tenant_id > 0 表示租户登录,只允许该租户下 (users.tenant_id = tenant_id) 的账号登录 - query := db.Preload("Passkeys").Where("email = ? OR phone = ?", req.EmailOrPhone, req.EmailOrPhone) + query := db.Preload("Passkeys").Where("email = ? OR phone = ?", identifier, identifier) if req.TenantID == 0 { - // 平台登录:只允许全局管理员账号 + // 全局登录:仅允许 tenant_id=0 账户。 + query = query.Where("tenant_id = 0") if err := query.First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + var tenantAccountCount int64 + if countErr := db.Model(&model.User{}). + Where("(email = ? OR phone = ?) AND tenant_id > 0", identifier, identifier). + Count(&tenantAccountCount).Error; countErr == nil && tenantAccountCount > 0 { + return LoginResult{}, ErrTenantAccountOnly + } + } return LoginResult{}, normalizeLoginQueryError(err) } - if !user.IsSuperAdmin() { + if req.Scope == ConsoleScopeAdmin && !user.IsSuperAdmin() { return LoginResult{}, ErrPlatformAdminOnly } } else { @@ -274,7 +313,13 @@ func (s Service) LoginV2(req LoginRequest) (LoginResult, error) { } // 不需要二次验证,直接登录 - tokens, err := GenerateTokenPair(user.ID) + tokenScope := ConsoleScopeUser + tokenTenantID := req.TenantID + if req.Scope == ConsoleScopeAdmin { + tokenScope = ConsoleScopeAdmin + tokenTenantID = 0 + } + tokens, err := GenerateTokenPairWithTenantAndScope(user.ID, tokenTenantID, tokenScope) if err != nil { return LoginResult{}, fmt.Errorf("%w: %v", ErrServiceUnavailable, err) } @@ -370,7 +415,7 @@ func (s Service) Verify2FA(req Verify2FARequest) (TokenPair, error) { default: return TokenPair{}, errors.New("unsupported 2FA type") } - return GenerateTokenPair(user.ID) + return GenerateTokenPairWithTenantAndScope(user.ID, tenantID, ConsoleScopeUser) } // setupFirstUserAsGlobalAdmin 设置第一个用户为全局管理员。 diff --git a/basaltpass-backend/internal/service/auth/service_test.go b/basaltpass-backend/internal/service/auth/service_test.go index a97257a5..4829c845 100644 --- a/basaltpass-backend/internal/service/auth/service_test.go +++ b/basaltpass-backend/internal/service/auth/service_test.go @@ -1,8 +1,16 @@ package auth import ( + "errors" "os" "testing" + + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + + "github.com/glebarez/sqlite" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" ) func TestGenerateTokenPair(t *testing.T) { @@ -15,3 +23,82 @@ func TestGenerateTokenPair(t *testing.T) { t.Fatalf("token pair invalid %v", err) } } + +func setupAuthLoginTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.Passkey{}, &model.TenantUser{}); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + common.SetDBForTest(db) + return db +} + +func mustPasswordHash(t *testing.T, raw string) string { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("generate password hash failed: %v", err) + } + return string(h) +} + +func TestLoginV2GlobalPortalRejectsTenantOnlyAccount(t *testing.T) { + db := setupAuthLoginTestDB(t) + + user := model.User{ + TenantID: 100, + Email: "tenant-only@example.com", + PasswordHash: mustPasswordHash(t, "pass-123"), + Nickname: "tenant-user", + EmailVerified: true, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create tenant user failed: %v", err) + } + + _, err := Service{}.LoginV2(LoginRequest{ + EmailOrPhone: user.Email, + Password: "pass-123", + TenantID: 0, + Scope: ConsoleScopeUser, + }) + + if !errors.Is(err, ErrTenantAccountOnly) { + t.Fatalf("expected ErrTenantAccountOnly, got %v", err) + } +} + +func TestLoginV2GlobalPortalAllowsGlobalAccount(t *testing.T) { + db := setupAuthLoginTestDB(t) + + user := model.User{ + TenantID: 0, + Email: "global@example.com", + PasswordHash: mustPasswordHash(t, "pass-456"), + Nickname: "global-user", + EmailVerified: true, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create global user failed: %v", err) + } + + res, err := Service{}.LoginV2(LoginRequest{ + EmailOrPhone: user.Email, + Password: "pass-456", + TenantID: 0, + Scope: ConsoleScopeUser, + }) + if err != nil { + t.Fatalf("global login should succeed, got error: %v", err) + } + if res.UserID != user.ID { + t.Fatalf("expected user id %d, got %d", user.ID, res.UserID) + } +} diff --git a/basaltpass-backend/internal/service/verification/service.go b/basaltpass-backend/internal/service/verification/service.go index 65071d59..0ec6e69a 100644 --- a/basaltpass-backend/internal/service/verification/service.go +++ b/basaltpass-backend/internal/service/verification/service.go @@ -123,6 +123,10 @@ func (s *Service) StartSignup(req StartSignupRequest) (*StartSignupResponse, err normalizedPhone = normalized } + if err := s.ensureSignupEmailAvailable(common.DB(), normalizedEmail, req.TenantID); err != nil { + return nil, err + } + // 风控评估 riskLevel := s.assessRisk(req.IP, req.UserAgent, normalizedEmail) @@ -375,11 +379,9 @@ func (s *Service) CompleteSignup(req CompleteSignupRequest) (*model.User, error) } }() - // 检查邮箱和租户组合是否已被注册(同一邮箱可以在不同租户注册) - var existingUser model.User - if err := tx.Where("email = ? AND tenant_id = ?", pendingSignup.Email, pendingSignup.TenantID).First(&existingUser).Error; err == nil { + if err := s.ensureSignupEmailAvailable(tx, strings.ToLower(strings.TrimSpace(pendingSignup.Email)), pendingSignup.TenantID); err != nil { tx.Rollback() - return nil, errors.New("email already registered in this tenant") + return nil, err } // 检查是否是第一个用户 @@ -471,6 +473,42 @@ func (s *Service) generateSignupID() (string, error) { return hex.EncodeToString(bytes), nil } +func (s *Service) ensureSignupEmailAvailable(tx *gorm.DB, email string, tenantID uint) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil + } + + if tenantID == 0 { + var exists int64 + if err := tx.Model(&model.User{}).Where("email = ?", email).Count(&exists).Error; err != nil { + return errors.New("failed to check existing account") + } + if exists > 0 { + return errors.New("email already registered") + } + return nil + } + + var sameTenant int64 + if err := tx.Model(&model.User{}).Where("email = ? AND tenant_id = ?", email, tenantID).Count(&sameTenant).Error; err != nil { + return errors.New("failed to check existing account") + } + if sameTenant > 0 { + return errors.New("email already registered in this tenant") + } + + var globalExists int64 + if err := tx.Model(&model.User{}).Where("email = ? AND tenant_id = 0", email).Count(&globalExists).Error; err != nil { + return errors.New("failed to check existing account") + } + if globalExists > 0 { + return errors.New("email already registered as a global account") + } + + return nil +} + // assessRisk 评估风险等级 func (s *Service) assessRisk(ip, userAgent, email string) string { // 这里实现简单的风险评估逻辑 diff --git a/basaltpass-frontend/src/features/auth/OAuthConsent.tsx b/basaltpass-frontend/src/features/auth/OAuthConsent.tsx index 7dec0f2d..96c511d7 100644 --- a/basaltpass-frontend/src/features/auth/OAuthConsent.tsx +++ b/basaltpass-frontend/src/features/auth/OAuthConsent.tsx @@ -1,8 +1,18 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useSearchParams } from 'react-router-dom' import client from '@api/client' import { ShieldCheckIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { useI18n } from '@shared/i18n' +import { getAccessToken } from '@utils/auth' +import { + pruneExpiredUserConsoleSessions, + type UserConsoleSession, +} from '@utils/userSessions' + +type SessionOption = UserConsoleSession & { + joinRequired: boolean + tenantMismatch: boolean +} export default function OAuthConsent() { const { t } = useI18n() @@ -23,11 +33,57 @@ export default function OAuthConsent() { const privacyPolicyUrl = searchParams.get('privacy_policy_url') || '' const termsOfServiceUrl = searchParams.get('terms_of_service_url') || '' const isVerified = searchParams.get('is_verified') === 'true' + const appTenantId = Number(searchParams.get('app_tenant_id') || 0) + const currentUserJoinRequired = searchParams.get('current_user_join_required') === 'true' + + const sessionOptions = useMemo(() => { + return pruneExpiredUserConsoleSessions().map((session) => { + const tenantId = Number(session.tenant_id || 0) + const joinRequired = appTenantId > 0 && tenantId === 0 + const tenantMismatch = appTenantId > 0 && tenantId > 0 && tenantId !== appTenantId + return { + ...session, + joinRequired, + tenantMismatch, + } + }) + }, [appTenantId]) + + const [selectedSessionKey, setSelectedSessionKey] = useState('') + const [confirmJoinTenant, setConfirmJoinTenant] = useState(false) + + useEffect(() => { + if (!sessionOptions.length) { + setSelectedSessionKey('') + return + } + + const currentToken = getAccessToken() + const currentSession = currentToken + ? sessionOptions.find((session) => session.token === currentToken) + : null + + if (currentSession) { + setSelectedSessionKey(currentSession.key) + return + } + + setSelectedSessionKey((prev) => { + if (prev && sessionOptions.some((session) => session.key === prev)) { + return prev + } + return sessionOptions[0].key + }) + }, [sessionOptions]) + + const selectedSession = useMemo(() => { + return sessionOptions.find((session) => session.key === selectedSessionKey) || null + }, [sessionOptions, selectedSessionKey]) const apiBase = client.defaults.baseURL || (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8101' const consentEndpoint = String(apiBase).replace(/\/$/, '') + '/api/v1/oauth/consent' - const submitConsentForm = (action: 'allow' | 'deny') => { + const submitConsentForm = (action: 'allow' | 'deny', opts?: { selectedToken?: string; joinTenant?: boolean }) => { if (submittedRef.current) return submittedRef.current = true @@ -50,6 +106,8 @@ export default function OAuthConsent() { if (state) append('state', state) if (codeChallenge) append('code_challenge', codeChallenge) if (codeChallengeMethod) append('code_challenge_method', codeChallengeMethod) + if (opts?.selectedToken) append('selected_access_token', opts.selectedToken) + if (opts?.joinTenant) append('join_tenant', 'true') document.body.appendChild(form) form.submit() @@ -60,7 +118,21 @@ export default function OAuthConsent() { setLoading(true) setError('') try { - submitConsentForm('allow') + const needsJoin = !!selectedSession?.joinRequired || (!selectedSession && currentUserJoinRequired) + if (selectedSession?.tenantMismatch) { + setError('Selected account does not belong to this application tenant. Please choose another account.') + setLoading(false) + return + } + if (needsJoin && !confirmJoinTenant) { + setError('Please confirm joining this tenant before continuing.') + setLoading(false) + return + } + submitConsentForm('allow', { + selectedToken: selectedSession?.token, + joinTenant: needsJoin && confirmJoinTenant, + }) } catch (err: any) { submittedRef.current = false setError(err.message || t('auth.oauthConsent.errors.authorizeFailed')) @@ -87,6 +159,10 @@ export default function OAuthConsent() { } }, [clientId, redirectUri, t]) + useEffect(() => { + setConfirmJoinTenant(false) + }, [selectedSessionKey]) + const getScopeDisplayName = (scope: string) => { const scopeNames: Record = { openid: t('auth.oauthConsent.scopes.openid.name'), @@ -221,6 +297,56 @@ export default function OAuthConsent() {

{t('auth.oauthConsent.permissions.title')}

+ + {sessionOptions.length > 0 ? ( +
+ + + {selectedSession?.tenantMismatch ? ( +

+ This account cannot authorize this app because its tenant does not match. +

+ ) : null} +
+ ) : null} + + {(selectedSession?.joinRequired || (!selectedSession && currentUserJoinRequired)) ? ( + + ) : null} +
{scopes.map((scope) => (
From bef63ddd01dec1d35c0948a146881e24bf8ccb66 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 02:48:42 -0700 Subject: [PATCH 02/21] feat(tenant): enforce active-tenant isolation across wallet order payment and subscription --- .../internal/handler/public/order/handler.go | 56 +++++++- .../handler/public/payment/handler.go | 40 +++++- .../handler/public/subscription/checkout.go | 45 +++++-- .../public/subscription/checkout_handler.go | 15 +++ .../handler/public/subscription/handler.go | 9 +- .../handler/public/subscription/service.go | 92 +++++++++++-- .../internal/handler/user/handler.go | 6 +- .../internal/handler/user/service.go | 41 ++++-- .../internal/handler/user/service_test.go | 4 +- .../internal/handler/user/wallet_handler.go | 14 +- .../internal/service/access/access_service.go | 43 +++++- .../internal/service/order/service.go | 81 +++++++++--- .../internal/service/payment/service.go | 122 +++++++++++++++--- .../internal/service/tenant/tenant_service.go | 64 ++++++--- .../internal/service/wallet/service.go | 117 ++++++++++++++--- 15 files changed, 619 insertions(+), 130 deletions(-) diff --git a/basaltpass-backend/internal/handler/public/order/handler.go b/basaltpass-backend/internal/handler/public/order/handler.go index fb6c0238..401d11e0 100644 --- a/basaltpass-backend/internal/handler/public/order/handler.go +++ b/basaltpass-backend/internal/handler/public/order/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" "github.com/gofiber/fiber/v2" ) @@ -20,11 +21,46 @@ func InitOrderHandler() { } } +func resolveOrderTenantID(c *fiber.Ctx) (uint64, error) { + if tenantLocal, ok := c.Locals("tenantID").(uint); ok && tenantLocal > 0 { + return uint64(tenantLocal), nil + } + + userID, ok := c.Locals("userID").(uint) + if !ok || userID == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "未登录") + } + + var user model.User + if err := common.DB().Select("id", "tenant_id").First(&user, userID).Error; err != nil { + return 0, fiber.NewError(fiber.StatusForbidden, "无法识别当前租户") + } + + if user.TenantID > 0 { + return uint64(user.TenantID), nil + } + + var tenantUser model.TenantUser + if err := common.DB().Select("tenant_id").Where("user_id = ?", userID).Order("created_at ASC").First(&tenantUser).Error; err != nil { + return 0, fiber.NewError(fiber.StatusForbidden, "当前用户没有关联租户") + } + + if tenantUser.TenantID == 0 { + return 0, fiber.NewError(fiber.StatusForbidden, "当前用户没有关联租户") + } + + return uint64(tenantUser.TenantID), nil +} + // CreateOrderHandler POST /orders - 创建订单 func CreateOrderHandler(c *fiber.Ctx) error { InitOrderHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveOrderTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } var req order2.CreateOrderRequest if err := c.BodyParser(&req); err != nil { @@ -40,7 +76,7 @@ func CreateOrderHandler(c *fiber.Ctx) error { }) } - order, err := orderService.CreateOrder(&req) + order, err := orderService.CreateOrder(&req, tenantID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": err.Error(), @@ -59,6 +95,10 @@ func GetOrderHandler(c *fiber.Ctx) error { InitOrderHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveOrderTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } orderIDStr := c.Params("id") orderID, err := strconv.ParseUint(orderIDStr, 10, 32) @@ -70,7 +110,7 @@ func GetOrderHandler(c *fiber.Ctx) error { activate := c.Query("activate") == "1" || c.Query("activate") == "true" - order, err := orderService.GetOrder(userID, uint(orderID), activate) + order, err := orderService.GetOrder(userID, uint(orderID), activate, tenantID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": err.Error(), @@ -88,9 +128,13 @@ func GetOrderByNumberHandler(c *fiber.Ctx) error { InitOrderHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveOrderTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } orderNumber := c.Params("number") - order, err := orderService.GetOrderByNumber(userID, orderNumber) + order, err := orderService.GetOrderByNumber(userID, orderNumber, tenantID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": err.Error(), @@ -108,6 +152,10 @@ func ListOrdersHandler(c *fiber.Ctx) error { InitOrderHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveOrderTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } limitStr := c.Query("limit", "20") limit, _ := strconv.Atoi(limitStr) @@ -115,7 +163,7 @@ func ListOrdersHandler(c *fiber.Ctx) error { limit = 100 } - orders, err := orderService.ListUserOrders(userID, limit) + orders, err := orderService.ListUserOrders(userID, limit, tenantID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": err.Error(), diff --git a/basaltpass-backend/internal/handler/public/payment/handler.go b/basaltpass-backend/internal/handler/public/payment/handler.go index eba5f8a4..22ffccd9 100644 --- a/basaltpass-backend/internal/handler/public/payment/handler.go +++ b/basaltpass-backend/internal/handler/public/payment/handler.go @@ -36,9 +36,35 @@ func requireSuperAdmin(c *fiber.Ctx) error { return nil } +func resolvePaymentTenantID(c *fiber.Ctx) uint { + if tid, ok := c.Locals("tenantID").(uint); ok && tid > 0 { + return tid + } + + userID, ok := c.Locals("userID").(uint) + if !ok || userID == 0 { + return 0 + } + + var user model.User + if err := common.DB().Select("id", "tenant_id").First(&user, userID).Error; err == nil { + if user.TenantID > 0 { + return user.TenantID + } + } + + var membership model.TenantUser + if err := common.DB().Select("tenant_id").Where("user_id = ?", userID).Order("created_at ASC").First(&membership).Error; err == nil { + return membership.TenantID + } + + return 0 +} + // CreatePaymentIntentHandler POST /payment/intents - 创建支付意图 func CreatePaymentIntentHandler(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) + activeTenantID := resolvePaymentTenantID(c) var req payment2.CreatePaymentIntentRequest if err := c.BodyParser(&req); err != nil { @@ -54,7 +80,7 @@ func CreatePaymentIntentHandler(c *fiber.Ctx) error { }) } - paymentIntent, mockResponse, err := payment2.CreatePaymentIntent(userID, req) + paymentIntent, mockResponse, err := payment2.CreatePaymentIntentForTenant(userID, activeTenantID, req) if err != nil { if errors.Is(err, payment2.ErrTenantStripeNotConfigured) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ @@ -80,6 +106,7 @@ func CreatePaymentIntentHandler(c *fiber.Ctx) error { // CreatePaymentSessionHandler POST /payment/sessions - 创建支付会话 func CreatePaymentSessionHandler(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) + activeTenantID := resolvePaymentTenantID(c) var req payment2.CreatePaymentSessionRequest if err := c.BodyParser(&req); err != nil { @@ -88,7 +115,7 @@ func CreatePaymentSessionHandler(c *fiber.Ctx) error { }) } - session, mockResponse, err := payment2.CreatePaymentSession(userID, req) + session, mockResponse, err := payment2.CreatePaymentSessionForTenant(userID, activeTenantID, req) if err != nil { if errors.Is(err, payment2.ErrTenantStripeNotConfigured) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ @@ -109,6 +136,7 @@ func CreatePaymentSessionHandler(c *fiber.Ctx) error { // GetPaymentIntentHandler GET /payment/intents/:id - 获取支付意图 func GetPaymentIntentHandler(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) + activeTenantID := resolvePaymentTenantID(c) paymentIntentIDStr := c.Params("id") paymentIntentID, err := strconv.ParseUint(paymentIntentIDStr, 10, 32) @@ -118,7 +146,7 @@ func GetPaymentIntentHandler(c *fiber.Ctx) error { }) } - paymentIntent, err := payment2.GetPaymentIntent(userID, uint(paymentIntentID)) + paymentIntent, err := payment2.GetPaymentIntentForTenant(userID, uint(paymentIntentID), activeTenantID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": "[GetPaymentIntentHandler] Payment intent not found", @@ -131,9 +159,10 @@ func GetPaymentIntentHandler(c *fiber.Ctx) error { // GetPaymentSessionHandler GET /payment/sessions/:session_id - 获取支付会话 func GetPaymentSessionHandler(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) + activeTenantID := resolvePaymentTenantID(c) sessionID := c.Params("session_id") - session, err := payment2.GetPaymentSession(userID, sessionID) + session, err := payment2.GetPaymentSessionForTenant(userID, sessionID, activeTenantID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": "Payment session not found", @@ -146,6 +175,7 @@ func GetPaymentSessionHandler(c *fiber.Ctx) error { // ListPaymentIntentsHandler GET /payment/intents - 获取支付意图列表 func ListPaymentIntentsHandler(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) + activeTenantID := resolvePaymentTenantID(c) limitStr := c.Query("limit", "20") limit, _ := strconv.Atoi(limitStr) @@ -153,7 +183,7 @@ func ListPaymentIntentsHandler(c *fiber.Ctx) error { limit = 100 // 限制最大查询数量 } - paymentIntents, err := payment2.ListPaymentIntents(userID, limit) + paymentIntents, err := payment2.ListPaymentIntentsForTenant(userID, activeTenantID, limit) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": err.Error(), diff --git a/basaltpass-backend/internal/handler/public/subscription/checkout.go b/basaltpass-backend/internal/handler/public/subscription/checkout.go index 89111eb3..06d10080 100644 --- a/basaltpass-backend/internal/handler/public/subscription/checkout.go +++ b/basaltpass-backend/internal/handler/public/subscription/checkout.go @@ -13,12 +13,13 @@ import ( // CheckoutRequest 订阅结账请求 type CheckoutRequest struct { - UserID uint `json:"user_id" validate:"required"` - PriceID uint `json:"price_id" validate:"required"` - Quantity float64 `json:"quantity,omitempty"` - CouponCode *string `json:"coupon_code,omitempty"` - SuccessURL string `json:"success_url" validate:"required"` - CancelURL string `json:"cancel_url" validate:"required"` + UserID uint `json:"user_id" validate:"required"` + PriceID uint `json:"price_id" validate:"required"` + Quantity float64 `json:"quantity,omitempty"` + CouponCode *string `json:"coupon_code,omitempty"` + SuccessURL string `json:"success_url" validate:"required"` + CancelURL string `json:"cancel_url" validate:"required"` + ActiveTenantID uint64 `json:"-"` } // CheckoutResponse 订阅结账响应 @@ -59,11 +60,30 @@ func (s *CheckoutService) CreateCheckout(req *CheckoutRequest) (*CheckoutRespons } return nil, fmt.Errorf("查询客户失败: %w", err) } - if user.TenantID == 0 { + userTenantID := req.ActiveTenantID + if userTenantID == 0 && user.TenantID > 0 { + userTenantID = uint64(user.TenantID) + } + if userTenantID == 0 { + var membership model.TenantUser + if err := tx.Select("tenant_id").Where("user_id = ?", req.UserID).Order("created_at ASC").First(&membership).Error; err == nil { + userTenantID = uint64(membership.TenantID) + } + } + if userTenantID == 0 { tx.Rollback() return nil, fmt.Errorf("用户未绑定租户") } - userTenantID := uint64(user.TenantID) + + hasIdentity, err := userHasTenantIdentity(tx, req.UserID, userTenantID) + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("校验用户租户身份失败: %w", err) + } + if !hasIdentity { + tx.Rollback() + return nil, fmt.Errorf("用户不属于当前租户") + } // 步骤2: 验证价格 var price model.Price @@ -74,7 +94,7 @@ func (s *CheckoutService) CreateCheckout(req *CheckoutRequest) (*CheckoutRespons } return nil, fmt.Errorf("查询价格失败: %w", err) } - if price.TenantID != nil && *price.TenantID != userTenantID { + if price.TenantID == nil || *price.TenantID != userTenantID { tx.Rollback() return nil, fmt.Errorf("价格不属于当前租户") } @@ -86,7 +106,7 @@ func (s *CheckoutService) CreateCheckout(req *CheckoutRequest) (*CheckoutRespons if req.CouponCode != nil && *req.CouponCode != "" { var c model.Coupon - if err := tx.Where("code = ? AND is_active = true AND (tenant_id IS NULL OR tenant_id = ?)", *req.CouponCode, userTenantID).First(&c).Error; err != nil { + if err := tx.Where("code = ? AND is_active = true AND tenant_id = ?", *req.CouponCode, userTenantID).First(&c).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("优惠券不存在或已失效") @@ -269,10 +289,11 @@ func (s *CheckoutService) CreateCheckout(req *CheckoutRequest) (*CheckoutRespons "invoice_id": invoice.ID, "payment_id": paymentRecord.ID, "source": "subscription_checkout", + "tenant_id": userTenantID, }, } - paymentIntent, _, err := payment.CreatePaymentIntent(req.UserID, paymentIntentReq) + paymentIntent, _, err := payment.CreatePaymentIntentForTenant(req.UserID, uint(userTenantID), paymentIntentReq) if err != nil { return nil, fmt.Errorf("创建支付意图失败: %w", err) } @@ -290,7 +311,7 @@ func (s *CheckoutService) CreateCheckout(req *CheckoutRequest) (*CheckoutRespons UserEmail: user.Email, } - paymentSession, sessionStripeResponse, err := payment.CreatePaymentSession(req.UserID, sessionReq) + paymentSession, sessionStripeResponse, err := payment.CreatePaymentSessionForTenant(req.UserID, uint(userTenantID), sessionReq) if err != nil { return nil, fmt.Errorf("创建支付会话失败: %w", err) } diff --git a/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go b/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go index baf64062..ff6579ce 100644 --- a/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go +++ b/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go @@ -20,6 +20,10 @@ func CheckoutHandler(c *fiber.Ctx) error { InitCheckoutHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveCurrentUserTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } var req CheckoutRequest if err := c.BodyParser(&req); err != nil { @@ -48,6 +52,10 @@ func CheckoutHandler(c *fiber.Ctx) error { }) } + if tenantID != nil { + req.ActiveTenantID = *tenantID + } + response, err := checkoutService.CreateCheckout(&req) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -67,6 +75,10 @@ func QuickCheckoutHandler(c *fiber.Ctx) error { InitCheckoutHandler() userID := c.Locals("userID").(uint) + tenantID, tenantErr := resolveCurrentUserTenantID(c) + if tenantErr != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": tenantErr.Error()}) + } var req struct { PriceID uint `json:"price_id" validate:"required"` @@ -89,6 +101,9 @@ func QuickCheckoutHandler(c *fiber.Ctx) error { SuccessURL: "http://localhost:5101/subscriptions?payment=success", CancelURL: "http://localhost:5101/subscriptions?payment=canceled", } + if tenantID != nil { + checkoutReq.ActiveTenantID = *tenantID + } response, err := checkoutService.CreateCheckout(&checkoutReq) if err != nil { diff --git a/basaltpass-backend/internal/handler/public/subscription/handler.go b/basaltpass-backend/internal/handler/public/subscription/handler.go index 18c63a58..70ade4d0 100644 --- a/basaltpass-backend/internal/handler/public/subscription/handler.go +++ b/basaltpass-backend/internal/handler/public/subscription/handler.go @@ -447,7 +447,14 @@ func (h *Handler) ValidateCoupon(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "优惠券代码不能为空"}) } - coupon, err := h.service.GetCouponByCode(code) + tenantID, tenantErr := resolveCurrentUserTenantID(c) + var coupon *model.Coupon + var err error + if tenantErr == nil && tenantID != nil { + coupon, err = h.service.GetCouponByCodeForTenant(code, tenantID) + } else { + coupon, err = h.service.GetCouponByCode(code) + } if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } diff --git a/basaltpass-backend/internal/handler/public/subscription/service.go b/basaltpass-backend/internal/handler/public/subscription/service.go index b8a4dcbe..06a1b22f 100644 --- a/basaltpass-backend/internal/handler/public/subscription/service.go +++ b/basaltpass-backend/internal/handler/public/subscription/service.go @@ -5,6 +5,8 @@ import ( paymentservice "basaltpass-backend/internal/service/payment" "errors" "fmt" + "strconv" + "strings" "time" "basaltpass-backend/internal/model" @@ -20,6 +22,57 @@ func NewService(db *gorm.DB) *Service { return &Service{db: db} } +func parseTenantIDFromMetadata(metadata map[string]interface{}) uint64 { + if metadata == nil { + return 0 + } + v, ok := metadata["tenant_id"] + if !ok { + return 0 + } + switch value := v.(type) { + case string: + n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err == nil { + return n + } + case float64: + return uint64(value) + case uint64: + return value + case uint: + return uint64(value) + case int: + if value >= 0 { + return uint64(value) + } + } + return 0 +} + +func userHasTenantIdentity(tx *gorm.DB, userID uint, tenantID uint64) (bool, error) { + if userID == 0 || tenantID == 0 { + return false, nil + } + + var user model.User + if err := tx.Select("tenant_id").First(&user, userID).Error; err != nil { + return false, err + } + if user.TenantID > 0 && uint64(user.TenantID) == tenantID { + return true, nil + } + + var membershipCount int64 + if err := tx.Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Count(&membershipCount).Error; err != nil { + return false, err + } + + return membershipCount > 0, nil +} + // ========== 产品管理 ========== // CreateProduct 创建产品 @@ -472,8 +525,17 @@ func (s *Service) CreateCoupon(req *subdto.CreateCouponRequest) (*model.Coupon, // GetCouponByCode 根据代码获取优惠券 func (s *Service) GetCouponByCode(code string) (*model.Coupon, error) { + return s.GetCouponByCodeForTenant(code, nil) +} + +func (s *Service) GetCouponByCodeForTenant(code string, tenantID *uint64) (*model.Coupon, error) { var coupon model.Coupon - if err := s.db.Where("code = ?", code).First(&coupon).Error; err != nil { + query := s.db.Where("code = ?", code) + if tenantID != nil { + query = query.Where("tenant_id = ?", *tenantID) + } + + if err := query.First(&coupon).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("优惠券不存在") } @@ -597,11 +659,25 @@ func (s *Service) CreateSubscription(req *subdto.CreateSubscriptionRequest) (*mo } return nil, fmt.Errorf("查询用户失败: %w", err) } - if user.TenantID == 0 { + + tenantID := parseTenantIDFromMetadata(req.Metadata) + if tenantID == 0 && user.TenantID > 0 { + tenantID = uint64(user.TenantID) + } + if tenantID == 0 { tx.Rollback() return nil, fmt.Errorf("用户未绑定租户") } - userTenantID := uint64(user.TenantID) + + hasIdentity, err := userHasTenantIdentity(tx, req.UserID, tenantID) + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("校验用户租户身份失败: %w", err) + } + if !hasIdentity { + tx.Rollback() + return nil, fmt.Errorf("用户不属于当前租户") + } // 验证价格存在 var price model.Price @@ -612,7 +688,7 @@ func (s *Service) CreateSubscription(req *subdto.CreateSubscriptionRequest) (*mo } return nil, fmt.Errorf("查询价格失败: %w", err) } - if price.TenantID != nil && *price.TenantID != userTenantID { + if price.TenantID == nil || *price.TenantID != tenantID { tx.Rollback() return nil, fmt.Errorf("价格不属于当前租户") } @@ -620,15 +696,11 @@ func (s *Service) CreateSubscription(req *subdto.CreateSubscriptionRequest) (*mo // 处理优惠券 var couponID *uint if req.CouponCode != nil { - coupon, err := s.GetCouponByCode(*req.CouponCode) + coupon, err := s.GetCouponByCodeForTenant(*req.CouponCode, &tenantID) if err != nil { tx.Rollback() return nil, err } - if coupon.TenantID != nil && *coupon.TenantID != userTenantID { - tx.Rollback() - return nil, fmt.Errorf("优惠券不属于当前租户") - } couponID = &coupon.ID } @@ -656,7 +728,7 @@ func (s *Service) CreateSubscription(req *subdto.CreateSubscriptionRequest) (*mo } subscription := &model.Subscription{ - TenantID: &userTenantID, + TenantID: &tenantID, UserID: req.UserID, Status: status, CurrentPriceID: req.PriceID, diff --git a/basaltpass-backend/internal/handler/user/handler.go b/basaltpass-backend/internal/handler/user/handler.go index 0f56cd33..8d133f47 100644 --- a/basaltpass-backend/internal/handler/user/handler.go +++ b/basaltpass-backend/internal/handler/user/handler.go @@ -16,7 +16,11 @@ var tenantSvc = tenant2.NewTenantService() // GetProfileHandler handles GET /user/profile func GetProfileHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) - profile, err := svc.GetProfile(uid) + var activeTenantID uint + if tid, ok := c.Locals("tenantID").(uint); ok { + activeTenantID = tid + } + profile, err := svc.GetProfile(uid, activeTenantID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) } diff --git a/basaltpass-backend/internal/handler/user/service.go b/basaltpass-backend/internal/handler/user/service.go index 19a193f4..a06b20c2 100644 --- a/basaltpass-backend/internal/handler/user/service.go +++ b/basaltpass-backend/internal/handler/user/service.go @@ -12,7 +12,7 @@ import ( type Service struct{} // GetProfile returns user profile by ID. -func (s Service) GetProfile(userID uint) (userdto.ProfileResponse, error) { +func (s Service) GetProfile(userID uint, activeTenantID uint) (userdto.ProfileResponse, error) { var u model.User if err := common.DB().First(&u, userID).Error; err != nil { return userdto.ProfileResponse{}, err @@ -26,21 +26,38 @@ func (s Service) GetProfile(userID uint) (userdto.ProfileResponse, error) { tenantRole string ) - // 普通租户用户始终以 users.tenant_id 作为其默认租户上下文。 - if u.TenantID > 0 { - tid := u.TenantID - tenantID = &tid - hasTenant = true + // activeTenantID 来自当前 token 的 tid,可支持全局用户切换到某个 tenant identity。 + resolvedTenantID := activeTenantID + if resolvedTenantID == 0 { + if u.TenantID > 0 { + resolvedTenantID = u.TenantID + } else { + var firstMembership model.TenantUser + if err := common.DB(). + Select("tenant_id"). + Where("user_id = ?", userID). + Order("created_at ASC"). + First(&firstMembership).Error; err == nil { + resolvedTenantID = firstMembership.TenantID + } + } } - // 平台管理员(tenant_id=0)不应因为 tenant_users 历史记录而自动获得租户控制台上下文。 - if u.TenantID > 0 { - var ta model.TenantUser + if resolvedTenantID > 0 { + var membership model.TenantUser if err := common.DB(). - Where("user_id = ? AND tenant_id = ?", userID, u.TenantID). + Where("user_id = ? AND tenant_id = ?", userID, resolvedTenantID). Order("created_at ASC"). - First(&ta).Error; err == nil { - tenantRole = string(ta.Role) + First(&membership).Error; err == nil { + hasTenant = true + tid := resolvedTenantID + tenantID = &tid + tenantRole = string(membership.Role) + } else if u.TenantID == resolvedTenantID { + hasTenant = true + tid := resolvedTenantID + tenantID = &tid + tenantRole = string(model.TenantRoleUser) } } diff --git a/basaltpass-backend/internal/handler/user/service_test.go b/basaltpass-backend/internal/handler/user/service_test.go index 5163cf22..ae642973 100644 --- a/basaltpass-backend/internal/handler/user/service_test.go +++ b/basaltpass-backend/internal/handler/user/service_test.go @@ -48,7 +48,7 @@ func TestGetProfileUsesUserTenantIDForRegularUser(t *testing.T) { } svc := Service{} - profile, err := svc.GetProfile(u.ID) + profile, err := svc.GetProfile(u.ID, 0) if err != nil { t.Fatalf("GetProfile failed: %v", err) } @@ -94,7 +94,7 @@ func TestGetProfileDoesNotDeriveTenantContextFromTenantUsersForPlatformAdmin(t * } svc := Service{} - profile, err := svc.GetProfile(u.ID) + profile, err := svc.GetProfile(u.ID, 0) if err != nil { t.Fatalf("GetProfile failed: %v", err) } diff --git a/basaltpass-backend/internal/handler/user/wallet_handler.go b/basaltpass-backend/internal/handler/user/wallet_handler.go index 7ee2855c..e86c8d50 100644 --- a/basaltpass-backend/internal/handler/user/wallet_handler.go +++ b/basaltpass-backend/internal/handler/user/wallet_handler.go @@ -13,8 +13,9 @@ import ( // GetWalletBalanceHandler GET /wallet/balance?currency=USD func GetWalletBalanceHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) + activeTenantID, _ := c.Locals("tenantID").(uint) currency := c.Query("currency", "USD") - w, err := wallet.GetBalanceByCode(uid, currency) + w, err := wallet.GetBalanceByCodeWithTenant(uid, activeTenantID, currency) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } @@ -24,6 +25,7 @@ func GetWalletBalanceHandler(c *fiber.Ctx) error { // RechargeWalletHandler POST /wallet/recharge {currency, amount} func RechargeWalletHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) + activeTenantID, _ := c.Locals("tenantID").(uint) var body struct { Currency string `json:"currency"` Amount int64 `json:"amount"` @@ -31,7 +33,7 @@ func RechargeWalletHandler(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } - if err := wallet.RechargeByCode(uid, body.Currency, body.Amount); err != nil { + if err := wallet.RechargeByCodeWithTenant(uid, activeTenantID, body.Currency, body.Amount); err != nil { if errors.Is(err, wallet.ErrWalletRechargeWithdrawDisabled) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -43,6 +45,7 @@ func RechargeWalletHandler(c *fiber.Ctx) error { // WithdrawWalletHandler POST /wallet/withdraw {currency, amount} func WithdrawWalletHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) + activeTenantID, _ := c.Locals("tenantID").(uint) var body struct { Currency string `json:"currency"` Amount int64 `json:"amount"` @@ -50,7 +53,7 @@ func WithdrawWalletHandler(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } - if err := wallet.WithdrawByCode(uid, body.Currency, body.Amount); err != nil { + if err := wallet.WithdrawByCodeWithTenant(uid, activeTenantID, body.Currency, body.Amount); err != nil { if errors.Is(err, wallet.ErrWalletRechargeWithdrawDisabled) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -62,15 +65,16 @@ func WithdrawWalletHandler(c *fiber.Ctx) error { // WalletHistoryHandler GET /wallet/history?currency=USD&limit=20 func WalletHistoryHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) + activeTenantID, _ := c.Locals("tenantID").(uint) currency := c.Query("currency", "") limitStr := c.Query("limit", "20") limit, _ := strconv.Atoi(limitStr) var txs interface{} var err error if currency == "" || currency == "all" { - txs, err = wallet.HistoryAllByUser(uid, limit) + txs, err = wallet.HistoryAllByUserWithTenant(uid, activeTenantID, limit) } else { - txs, err = wallet.HistoryByCode(uid, currency, limit) + txs, err = wallet.HistoryByCodeWithTenant(uid, activeTenantID, currency, limit) } if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) diff --git a/basaltpass-backend/internal/service/access/access_service.go b/basaltpass-backend/internal/service/access/access_service.go index a3db2b42..bb9b6703 100644 --- a/basaltpass-backend/internal/service/access/access_service.go +++ b/basaltpass-backend/internal/service/access/access_service.go @@ -35,16 +35,34 @@ func (s *Service) ResolveTenantContext(userID uint, requestedTenantID uint) (uin return 0, "", err } - if user.TenantID == 0 { + tenantID := requestedTenantID + if tenantID == 0 { + if user.TenantID > 0 { + tenantID = user.TenantID + } else { + var membership model.TenantUser + if err := s.db.Select("tenant_id").Where("user_id = ?", userID).Order("created_at ASC").First(&membership).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, "", ErrNoTenantAssociation + } + return 0, "", err + } + tenantID = membership.TenantID + } + } + + if tenantID == 0 { return 0, "", ErrNoTenantAssociation } - tenantID := user.TenantID - if requestedTenantID > 0 { - if requestedTenantID != user.TenantID { + if user.TenantID != tenantID { + var membershipCount int64 + if err := s.db.Model(&model.TenantUser{}).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Count(&membershipCount).Error; err != nil { + return 0, "", err + } + if membershipCount == 0 { return 0, "", ErrInvalidTenantAssociation } - tenantID = requestedTenantID } var tenant model.Tenant @@ -72,6 +90,10 @@ func (s *Service) ResolveTenantContext(userID uint, requestedTenantID uint) (uin // GetTenantRole returns user's role in a tenant. func (s *Service) GetTenantRole(userID, tenantID uint) (model.TenantRole, error) { + if tenantID == 0 { + return "", ErrTenantMembershipNotFound + } + var user model.User if err := s.db.Select("id", "tenant_id").First(&user, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -79,8 +101,15 @@ func (s *Service) GetTenantRole(userID, tenantID uint) (model.TenantRole, error) } return "", err } - if user.TenantID != tenantID || tenantID == 0 { - return "", ErrTenantMembershipNotFound + + if user.TenantID != tenantID { + var membershipCount int64 + if err := s.db.Model(&model.TenantUser{}).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Count(&membershipCount).Error; err != nil { + return "", err + } + if membershipCount == 0 { + return "", ErrTenantMembershipNotFound + } } var tenantUser model.TenantUser diff --git a/basaltpass-backend/internal/service/order/service.go b/basaltpass-backend/internal/service/order/service.go index 285677cb..58583304 100644 --- a/basaltpass-backend/internal/service/order/service.go +++ b/basaltpass-backend/internal/service/order/service.go @@ -64,7 +64,7 @@ func (s *OrderService) generateOrderNumber() string { } // CreateOrder 创建订单 -func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, error) { +func (s *OrderService) CreateOrder(req *CreateOrderRequest, tenantID uint64) (*OrderResponse, error) { var result *OrderResponse err := s.db.Transaction(func(tx *gorm.DB) error { // 验证用户 @@ -78,7 +78,11 @@ func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, err // 验证价格 var price model.Price - if err := tx.Preload("Plan.Product").First(&price, req.PriceID).Error; err != nil { + priceQuery := tx.Preload("Plan.Product") + if tenantID > 0 { + priceQuery = priceQuery.Where("id = ? AND tenant_id = ?", req.PriceID, tenantID) + } + if err := priceQuery.First(&price).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("价格不存在") } @@ -92,7 +96,11 @@ func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, err if req.CouponCode != nil && *req.CouponCode != "" { var c model.Coupon - if err := tx.Where("code = ? AND is_active = true", *req.CouponCode).First(&c).Error; err != nil { + couponQuery := tx.Where("code = ? AND is_active = true", *req.CouponCode) + if tenantID > 0 { + couponQuery = couponQuery.Where("tenant_id = ?", tenantID) + } + if err := couponQuery.First(&c).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("优惠券不存在或已失效") } @@ -134,6 +142,11 @@ func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, err } // 创建订单 + metadata := model.JSONB{"source": "web_order"} + if tenantID > 0 { + metadata["tenant_id"] = tenantID + } + order := &model.Order{ OrderNumber: s.generateOrderNumber(), UserID: req.UserID, @@ -147,7 +160,7 @@ func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, err Currency: price.Currency, Description: fmt.Sprintf("订阅:%s - %s", price.Plan.Product.Name, price.Plan.DisplayName), ExpiresAt: time.Now().Add(30 * time.Minute), // 30分钟内支付 - Metadata: model.JSONB{"source": "web_order"}, + Metadata: metadata, } if err := tx.Create(order).Error; err != nil { @@ -185,10 +198,16 @@ func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*OrderResponse, err } // GetOrder 获取订单 -func (s *OrderService) GetOrder(userID uint, orderID uint, activate bool) (*OrderResponse, error) { +func (s *OrderService) GetOrder(userID uint, orderID uint, activate bool, tenantID uint64) (*OrderResponse, error) { + query := s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). + Where("market_orders.id = ? AND market_orders.user_id = ?", orderID, userID) + if tenantID > 0 { + query = query.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } + var order model.Order - if err := s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). - Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { + if err := query.First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("订单不存在") } @@ -197,17 +216,32 @@ func (s *OrderService) GetOrder(userID uint, orderID uint, activate bool) (*Orde if activate && order.Status == model.OrderStatusPending && order.PaymentSession != nil { _ = paymentservice.FinalizeOrderPaymentBySessionForUser(userID, order.PaymentSession.StripeSessionID) - _ = s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). - Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error + reloadQuery := s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). + Where("market_orders.id = ? AND market_orders.user_id = ?", orderID, userID) + if tenantID > 0 { + reloadQuery = reloadQuery.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } + _ = reloadQuery.First(&order).Error } if activate && order.Status == model.OrderStatusPending { _ = paymentservice.ReconcileUserOrderPaymentsFromStripe(userID) - _ = s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). - Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error + reloadQuery := s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). + Where("market_orders.id = ? AND market_orders.user_id = ?", orderID, userID) + if tenantID > 0 { + reloadQuery = reloadQuery.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } + _ = reloadQuery.First(&order).Error if order.Status == model.OrderStatusPending && order.PaymentSession != nil { _ = paymentservice.FinalizeOrderPaymentBySessionForUser(userID, order.PaymentSession.StripeSessionID) - _ = s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). - Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error + reloadQuery = s.db.Preload("Price.Plan.Product").Preload("Coupon").Preload("PaymentSession"). + Where("market_orders.id = ? AND market_orders.user_id = ?", orderID, userID) + if tenantID > 0 { + reloadQuery = reloadQuery.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } + _ = reloadQuery.First(&order).Error } } @@ -234,10 +268,16 @@ func (s *OrderService) GetOrder(userID uint, orderID uint, activate bool) (*Orde } // GetOrderByNumber 根据订单号获取订单 -func (s *OrderService) GetOrderByNumber(userID uint, orderNumber string) (*OrderResponse, error) { +func (s *OrderService) GetOrderByNumber(userID uint, orderNumber string, tenantID uint64) (*OrderResponse, error) { + query := s.db.Preload("Price.Plan.Product").Preload("Coupon"). + Where("market_orders.order_number = ? AND market_orders.user_id = ?", orderNumber, userID) + if tenantID > 0 { + query = query.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } + var order model.Order - if err := s.db.Preload("Price.Plan.Product").Preload("Coupon"). - Where("order_number = ? AND user_id = ?", orderNumber, userID).First(&order).Error; err != nil { + if err := query.First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("订单不存在") } @@ -282,10 +322,15 @@ func (s *OrderService) UpdateOrderStatus(orderID uint, status model.OrderStatus) } // ListUserOrders 获取用户订单列表 -func (s *OrderService) ListUserOrders(userID uint, limit int) ([]*OrderResponse, error) { +func (s *OrderService) ListUserOrders(userID uint, limit int, tenantID uint64) ([]*OrderResponse, error) { var orders []model.Order query := s.db.Preload("Price.Plan.Product").Preload("Coupon"). - Where("user_id = ?", userID).Order("created_at desc") + Where("market_orders.user_id = ?", userID).Order("market_orders.created_at desc") + + if tenantID > 0 { + query = query.Joins("JOIN market_prices ON market_prices.id = market_orders.price_id"). + Where("market_prices.tenant_id = ?", tenantID) + } if limit > 0 { query = query.Limit(limit) diff --git a/basaltpass-backend/internal/service/payment/service.go b/basaltpass-backend/internal/service/payment/service.go index 484fe0e2..ef9b2cd7 100644 --- a/basaltpass-backend/internal/service/payment/service.go +++ b/basaltpass-backend/internal/service/payment/service.go @@ -81,18 +81,13 @@ func generateStripeID(prefix string) string { return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(bytes)) } -func resolveTenantStripeConfigByUser(db *gorm.DB, userID uint) (*tenantStripeConfig, error) { - var user model.User - if err := db.Select("id", "tenant_id").First(&user, userID).Error; err != nil { - return nil, err - } - - if user.TenantID == 0 { +func resolveTenantStripeConfigByTenantID(db *gorm.DB, tenantID uint) (*tenantStripeConfig, error) { + if tenantID == 0 { return &tenantStripeConfig{SecretKey: "sk_test_legacy_placeholder", TenantID: 0}, nil } var tenant model.Tenant - if err := db.Select("id", "metadata").First(&tenant, user.TenantID).Error; err != nil { + if err := db.Select("id", "metadata").First(&tenant, tenantID).Error; err != nil { return nil, err } @@ -125,6 +120,15 @@ func resolveTenantStripeConfigByUser(db *gorm.DB, userID uint) (*tenantStripeCon return config, nil } +func resolveTenantStripeConfigByUser(db *gorm.DB, userID uint) (*tenantStripeConfig, error) { + var user model.User + if err := db.Select("id", "tenant_id").First(&user, userID).Error; err != nil { + return nil, err + } + + return resolveTenantStripeConfigByTenantID(db, user.TenantID) +} + func parseString(v interface{}) string { s, _ := v.(string) return strings.TrimSpace(s) @@ -437,6 +441,20 @@ func parseUIntFromAny(v interface{}) uint { return 0 } +func parseTenantIDFromRawMetadata(raw string) uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + + var metadata map[string]interface{} + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return 0 + } + + return parseUIntFromAny(metadata["tenant_id"]) +} + func processStripeCheckoutSessionEvent(tx *gorm.DB, eventType string, eventObject map[string]interface{}) error { stripeSessionID := parseString(eventObject["id"]) if stripeSessionID == "" { @@ -472,7 +490,8 @@ func processStripeCheckoutSessionEvent(tx *gorm.DB, eventType string, eventObjec } if !wasComplete { - if err := wallet.RechargeByCode(session.UserID, session.Currency, session.Amount); err != nil { + tenantID := parseTenantIDFromRawMetadata(session.PaymentIntent.Metadata) + if err := wallet.RechargeByCodeWithTenant(session.UserID, tenantID, session.Currency, session.Amount); err != nil { return fmt.Errorf("failed to update wallet: %w", err) } } @@ -687,8 +706,21 @@ func GetWebhookEventStatus(eventID string) (*WebhookEventStatus, error) { // CreatePaymentIntent 创建支付意图 func CreatePaymentIntent(userID uint, req CreatePaymentIntentRequest) (*model.PaymentIntent, *MockStripeResponse, error) { + return CreatePaymentIntentForTenant(userID, 0, req) +} + +// CreatePaymentIntentForTenant creates payment intent in explicit tenant context. +func CreatePaymentIntentForTenant(userID uint, tenantID uint, req CreatePaymentIntentRequest) (*model.PaymentIntent, *MockStripeResponse, error) { db := common.DB() - stripeConfig, err := resolveTenantStripeConfigByUser(db, userID) + var ( + stripeConfig *tenantStripeConfig + err error + ) + if tenantID > 0 { + stripeConfig, err = resolveTenantStripeConfigByTenantID(db, tenantID) + } else { + stripeConfig, err = resolveTenantStripeConfigByUser(db, userID) + } if err != nil { return nil, nil, err } @@ -715,8 +747,12 @@ func CreatePaymentIntent(userID uint, req CreatePaymentIntentRequest) (*model.Pa if req.Metadata == nil { req.Metadata = map[string]interface{}{} } - if stripeConfig.TenantID > 0 { - req.Metadata["tenant_id"] = strconv.FormatUint(uint64(stripeConfig.TenantID), 10) + effectiveTenantID := stripeConfig.TenantID + if tenantID > 0 { + effectiveTenantID = tenantID + } + if effectiveTenantID > 0 { + req.Metadata["tenant_id"] = strconv.FormatUint(uint64(effectiveTenantID), 10) } req.Metadata["user_id"] = strconv.FormatUint(uint64(userID), 10) @@ -766,7 +802,7 @@ func CreatePaymentIntent(userID uint, req CreatePaymentIntentRequest) (*model.Pa }, RequestBody: req, Response: stripePIBody, - Timestamp: time.Now(), + Timestamp: time.Now(), } return &paymentIntent, mockResponse, nil @@ -774,8 +810,21 @@ func CreatePaymentIntent(userID uint, req CreatePaymentIntentRequest) (*model.Pa // CreatePaymentSession 创建支付会话 func CreatePaymentSession(userID uint, req CreatePaymentSessionRequest) (*model.PaymentSession, *MockStripeResponse, error) { + return CreatePaymentSessionForTenant(userID, 0, req) +} + +// CreatePaymentSessionForTenant creates payment session in explicit tenant context. +func CreatePaymentSessionForTenant(userID uint, tenantID uint, req CreatePaymentSessionRequest) (*model.PaymentSession, *MockStripeResponse, error) { db := common.DB() - stripeConfig, err := resolveTenantStripeConfigByUser(db, userID) + var ( + stripeConfig *tenantStripeConfig + err error + ) + if tenantID > 0 { + stripeConfig, err = resolveTenantStripeConfigByTenantID(db, tenantID) + } else { + stripeConfig, err = resolveTenantStripeConfigByUser(db, userID) + } if err != nil { return nil, nil, err } @@ -786,6 +835,12 @@ func CreatePaymentSession(userID uint, req CreatePaymentSessionRequest) (*model. if err := db.Where("id = ? AND user_id = ?", req.PaymentIntentID, userID).First(&paymentIntent).Error; err != nil { return nil, nil, errors.New("[CreatePaymentSession] payment intent not found. userID: " + fmt.Sprintf("%d", userID) + " paymentIntentID: " + fmt.Sprintf("%d", req.PaymentIntentID)) } + if tenantID > 0 { + intentTenantID := parseTenantIDFromRawMetadata(paymentIntent.Metadata) + if intentTenantID != tenantID { + return nil, nil, errors.New("payment intent tenant mismatch") + } + } stripeSessionBody, err := stripeRequest(stripeConfig.SecretKey, "https://api.stripe.com/v1/checkout/sessions", buildStripeCheckoutSessionForm(&paymentIntent, req)) if err != nil { @@ -842,7 +897,7 @@ func CreatePaymentSession(userID uint, req CreatePaymentSessionRequest) (*model. }, RequestBody: req, Response: stripeSessionBody, - Timestamp: time.Now(), + Timestamp: time.Now(), } return &session, mockResponse, nil @@ -932,7 +987,8 @@ func SimulatePayment(sessionID string, success bool) (*MockStripeResponse, error // 如果支付成功,更新用户钱包 if success { if !wasComplete { - if err := wallet.RechargeByCode(session.UserID, session.Currency, session.Amount); err != nil { + tenantID := parseTenantIDFromRawMetadata(session.PaymentIntent.Metadata) + if err := wallet.RechargeByCodeWithTenant(session.UserID, tenantID, session.Currency, session.Amount); err != nil { return nil, fmt.Errorf("failed to update wallet: %w", err) } } @@ -1500,24 +1556,42 @@ func calculatePeriodEnd(start time.Time, price *model.Price) time.Time { // GetPaymentIntent 获取支付意图 func GetPaymentIntent(userID uint, paymentIntentID uint) (*model.PaymentIntent, error) { + return GetPaymentIntentForTenant(userID, paymentIntentID, 0) +} + +func GetPaymentIntentForTenant(userID uint, paymentIntentID uint, tenantID uint) (*model.PaymentIntent, error) { db := common.DB() var paymentIntent model.PaymentIntent if err := db.Where("id = ? AND user_id = ?", paymentIntentID, userID).First(&paymentIntent).Error; err != nil { return nil, err } + if tenantID > 0 { + if parseTenantIDFromRawMetadata(paymentIntent.Metadata) != tenantID { + return nil, gorm.ErrRecordNotFound + } + } return &paymentIntent, nil } // GetPaymentSession 获取支付会话 func GetPaymentSession(userID uint, sessionID string) (*model.PaymentSession, error) { + return GetPaymentSessionForTenant(userID, sessionID, 0) +} + +func GetPaymentSessionForTenant(userID uint, sessionID string, tenantID uint) (*model.PaymentSession, error) { db := common.DB() var session model.PaymentSession if err := db.Preload("PaymentIntent").Where("stripe_session_id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { return nil, err } + if tenantID > 0 { + if parseTenantIDFromRawMetadata(session.PaymentIntent.Metadata) != tenantID { + return nil, gorm.ErrRecordNotFound + } + } return &session, nil } @@ -1536,6 +1610,10 @@ func GetPaymentSessionByStripeID(sessionID string) (*model.PaymentSession, error // ListPaymentIntents 获取用户的支付意图列表 func ListPaymentIntents(userID uint, limit int) ([]model.PaymentIntent, error) { + return ListPaymentIntentsForTenant(userID, 0, limit) +} + +func ListPaymentIntentsForTenant(userID uint, tenantID uint, limit int) ([]model.PaymentIntent, error) { db := common.DB() var paymentIntents []model.PaymentIntent @@ -1547,6 +1625,16 @@ func ListPaymentIntents(userID uint, limit int) ([]model.PaymentIntent, error) { if err := query.Find(&paymentIntents).Error; err != nil { return nil, err } + if tenantID == 0 { + return paymentIntents, nil + } + + filtered := make([]model.PaymentIntent, 0, len(paymentIntents)) + for _, item := range paymentIntents { + if parseTenantIDFromRawMetadata(item.Metadata) == tenantID { + filtered = append(filtered, item) + } + } - return paymentIntents, nil + return filtered, nil } diff --git a/basaltpass-backend/internal/service/tenant/tenant_service.go b/basaltpass-backend/internal/service/tenant/tenant_service.go index 386eda86..90f5bb42 100644 --- a/basaltpass-backend/internal/service/tenant/tenant_service.go +++ b/basaltpass-backend/internal/service/tenant/tenant_service.go @@ -376,33 +376,65 @@ func (s *TenantService) ListTenants(page, pageSize int, search string) ([]*Tenan func (s *TenantService) GetUserTenants(userID uint) ([]*TenantResponse, error) { var user model.User if err := s.db.Select("tenant_id").First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []*TenantResponse{}, nil + } return nil, err } - if user.TenantID == 0 { - return []*TenantResponse{}, nil + + var memberships []model.TenantUser + if err := s.db.Where("user_id = ?", userID).Order("created_at ASC").Find(&memberships).Error; err != nil { + return nil, err } - var tenant model.Tenant - if err := s.db.First(&tenant, user.TenantID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return []*TenantResponse{}, nil + tenantOrder := make([]uint, 0, len(memberships)+1) + rolesByTenant := make(map[uint]model.TenantRole) + + if user.TenantID > 0 { + tenantOrder = append(tenantOrder, user.TenantID) + rolesByTenant[user.TenantID] = model.TenantRoleUser + } + + for _, membership := range memberships { + if membership.TenantID == 0 { + continue } - return nil, err + if _, ok := rolesByTenant[membership.TenantID]; !ok { + tenantOrder = append(tenantOrder, membership.TenantID) + } + rolesByTenant[membership.TenantID] = membership.Role } - resp := s.tenantToResponse(&tenant, nil) - if resp.Metadata == nil { - resp.Metadata = make(map[string]interface{}) + if len(tenantOrder) == 0 { + return []*TenantResponse{}, nil } - var tenantUser model.TenantUser - if err := s.db.Where("user_id = ? AND tenant_id = ?", userID, user.TenantID).First(&tenantUser).Error; err == nil { - resp.Metadata["user_role"] = string(tenantUser.Role) - } else { - resp.Metadata["user_role"] = string(model.TenantRoleUser) + tenantsByID := make(map[uint]*model.Tenant) + responses := make([]*TenantResponse, 0, len(tenantOrder)) + + for _, tenantID := range tenantOrder { + tenant, ok := tenantsByID[tenantID] + if !ok { + loaded := &model.Tenant{} + if err := s.db.First(loaded, tenantID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return nil, err + } + tenantsByID[tenantID] = loaded + tenant = loaded + } + + resp := s.tenantToResponse(tenant, nil) + if resp.Metadata == nil { + resp.Metadata = make(map[string]interface{}) + } + resp.Metadata["user_role"] = string(rolesByTenant[tenantID]) + responses = append(responses, resp) } - return []*TenantResponse{resp}, nil + return responses, nil } // UpdateTenant 更新租户 diff --git a/basaltpass-backend/internal/service/wallet/service.go b/basaltpass-backend/internal/service/wallet/service.go index fda22d95..8f9a08a5 100644 --- a/basaltpass-backend/internal/service/wallet/service.go +++ b/basaltpass-backend/internal/service/wallet/service.go @@ -32,6 +32,10 @@ func resolveCreditCurrency(tx *gorm.DB) (model.Currency, error) { } func resolveUserTenantID(tx *gorm.DB, userID uint) (uint, error) { + return resolveEffectiveTenantID(tx, userID, 0) +} + +func resolveEffectiveTenantID(tx *gorm.DB, userID uint, requestedTenantID uint) (uint, error) { if userID == 0 { return 0, errors.New("invalid user id") } @@ -40,7 +44,37 @@ func resolveUserTenantID(tx *gorm.DB, userID uint) (uint, error) { if err := tx.Select("tenant_id").First(&user, userID).Error; err != nil { return 0, err } - return user.TenantID, nil + + if requestedTenantID == 0 { + if user.TenantID > 0 { + return user.TenantID, nil + } + + var membership model.TenantUser + if err := tx.Select("tenant_id").Where("user_id = ?", userID).Order("created_at ASC").First(&membership).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, errors.New("user has no tenant identity") + } + return 0, err + } + return membership.TenantID, nil + } + + if user.TenantID == requestedTenantID { + return requestedTenantID, nil + } + + var membershipCount int64 + if err := tx.Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ?", userID, requestedTenantID). + Count(&membershipCount).Error; err != nil { + return 0, err + } + if membershipCount == 0 { + return 0, errors.New("user does not belong to requested tenant") + } + + return requestedTenantID, nil } // EnsureUserCreditWalletTx ensures one credit wallet exists for user under current transaction. @@ -107,6 +141,11 @@ func EnsureCreditWalletsForAllUsers() (int64, error) { // GetBalance returns wallet balance for user+currency (creates row if absent) func GetBalance(userID uint, currencyID uint) (model.Wallet, error) { + return GetBalanceWithTenant(userID, currencyID, 0) +} + +// GetBalanceWithTenant returns wallet balance in specified tenant context. +func GetBalanceWithTenant(userID uint, currencyID uint, tenantID uint) (model.Wallet, error) { // 验证货币是否存在 _, err := currency.GetCurrencyByID(currencyID) if err != nil { @@ -114,14 +153,14 @@ func GetBalance(userID uint, currencyID uint) (model.Wallet, error) { } db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return model.Wallet{}, err } var w model.Wallet - if err := db.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, tenantID).First(&w).Error; err != nil { - w = model.Wallet{TenantID: tenantID, UserID: &userID, CurrencyID: ¤cyID} + if err := db.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, effectiveTenantID).First(&w).Error; err != nil { + w = model.Wallet{TenantID: effectiveTenantID, UserID: &userID, CurrencyID: ¤cyID} if err := db.Create(&w).Error; err != nil { return model.Wallet{}, err } @@ -131,16 +170,26 @@ func GetBalance(userID uint, currencyID uint) (model.Wallet, error) { // GetBalanceByCode returns wallet balance for user+currency code (convenience function) func GetBalanceByCode(userID uint, currencyCode string) (model.Wallet, error) { + return GetBalanceByCodeWithTenant(userID, 0, currencyCode) +} + +// GetBalanceByCodeWithTenant returns wallet balance by code in tenant context. +func GetBalanceByCodeWithTenant(userID uint, tenantID uint, currencyCode string) (model.Wallet, error) { // 根据代码获取货币ID curr, err := currency.GetCurrencyByCode(currencyCode) if err != nil { return model.Wallet{}, errors.New("invalid currency code") } - return GetBalance(userID, curr.ID) + return GetBalanceWithTenant(userID, curr.ID, tenantID) } // Recharge adds amount to balance and creates transaction (mock auto success) func Recharge(userID uint, currencyID uint, amount int64) error { + return RechargeWithTenant(userID, 0, currencyID, amount) +} + +// RechargeWithTenant adds amount under specified tenant context. +func RechargeWithTenant(userID uint, tenantID uint, currencyID uint, amount int64) error { if !RechargeWithdrawEnabled() { return ErrWalletRechargeWithdrawDisabled } @@ -155,15 +204,15 @@ func Recharge(userID uint, currencyID uint, amount int64) error { } db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return err } return db.Transaction(func(tx *gorm.DB) error { var w model.Wallet - if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, tenantID). - FirstOrCreate(&w, model.Wallet{TenantID: tenantID, UserID: &userID, CurrencyID: ¤cyID}).Error; err != nil { + if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, effectiveTenantID). + FirstOrCreate(&w, model.Wallet{TenantID: effectiveTenantID, UserID: &userID, CurrencyID: ¤cyID}).Error; err != nil { return err } w.Balance += amount @@ -177,15 +226,23 @@ func Recharge(userID uint, currencyID uint, amount int64) error { // RechargeByCode adds amount to balance using currency code (convenience function) func RechargeByCode(userID uint, currencyCode string, amount int64) error { + return RechargeByCodeWithTenant(userID, 0, currencyCode, amount) +} + +func RechargeByCodeWithTenant(userID uint, tenantID uint, currencyCode string, amount int64) error { curr, err := currency.GetCurrencyByCode(currencyCode) if err != nil { return errors.New("invalid currency code") } - return Recharge(userID, curr.ID, amount) + return RechargeWithTenant(userID, tenantID, curr.ID, amount) } // Withdraw deducts amount (mock immediate success) func Withdraw(userID uint, currencyID uint, amount int64) error { + return WithdrawWithTenant(userID, 0, currencyID, amount) +} + +func WithdrawWithTenant(userID uint, tenantID uint, currencyID uint, amount int64) error { if !RechargeWithdrawEnabled() { return ErrWalletRechargeWithdrawDisabled } @@ -200,14 +257,14 @@ func Withdraw(userID uint, currencyID uint, amount int64) error { } db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return err } return db.Transaction(func(tx *gorm.DB) error { var w model.Wallet - if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, tenantID).First(&w).Error; err != nil { + if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, effectiveTenantID).First(&w).Error; err != nil { return err } if w.Balance < amount { @@ -224,15 +281,23 @@ func Withdraw(userID uint, currencyID uint, amount int64) error { // WithdrawByCode deducts amount using currency code (convenience function) func WithdrawByCode(userID uint, currencyCode string, amount int64) error { + return WithdrawByCodeWithTenant(userID, 0, currencyCode, amount) +} + +func WithdrawByCodeWithTenant(userID uint, tenantID uint, currencyCode string, amount int64) error { curr, err := currency.GetCurrencyByCode(currencyCode) if err != nil { return errors.New("invalid currency code") } - return Withdraw(userID, curr.ID, amount) + return WithdrawWithTenant(userID, tenantID, curr.ID, amount) } // AdjustByCode changes wallet balance by delta in smallest unit and records a transaction. func AdjustByCode(userID uint, currencyCode string, delta int64, txType string, reference string) (model.Wallet, error) { + return AdjustByCodeWithTenant(userID, 0, currencyCode, delta, txType, reference) +} + +func AdjustByCodeWithTenant(userID uint, tenantID uint, currencyCode string, delta int64, txType string, reference string) (model.Wallet, error) { if delta == 0 { return model.Wallet{}, errors.New("amount must not be zero") } @@ -243,7 +308,7 @@ func AdjustByCode(userID uint, currencyCode string, delta int64, txType string, } db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return model.Wallet{}, err } @@ -251,8 +316,8 @@ func AdjustByCode(userID uint, currencyCode string, delta int64, txType string, var updated model.Wallet err = db.Transaction(func(tx *gorm.DB) error { var w model.Wallet - if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, curr.ID, tenantID). - FirstOrCreate(&w, model.Wallet{TenantID: tenantID, UserID: &userID, CurrencyID: &curr.ID}).Error; err != nil { + if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, curr.ID, effectiveTenantID). + FirstOrCreate(&w, model.Wallet{TenantID: effectiveTenantID, UserID: &userID, CurrencyID: &curr.ID}).Error; err != nil { return err } @@ -297,14 +362,18 @@ func AdjustByCode(userID uint, currencyCode string, delta int64, txType string, // History returns last n transactions func History(userID uint, currencyID uint, limit int) ([]model.WalletTx, error) { + return HistoryWithTenant(userID, 0, currencyID, limit) +} + +func HistoryWithTenant(userID uint, tenantID uint, currencyID uint, limit int) ([]model.WalletTx, error) { var w model.Wallet db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return nil, err } - if err := db.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, tenantID).First(&w).Error; err != nil { + if err := db.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, currencyID, effectiveTenantID).First(&w).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return []model.WalletTx{}, nil } @@ -317,28 +386,36 @@ func History(userID uint, currencyID uint, limit int) ([]model.WalletTx, error) // HistoryByCode returns last n transactions using currency code (convenience function) func HistoryByCode(userID uint, currencyCode string, limit int) ([]model.WalletTx, error) { + return HistoryByCodeWithTenant(userID, 0, currencyCode, limit) +} + +func HistoryByCodeWithTenant(userID uint, tenantID uint, currencyCode string, limit int) ([]model.WalletTx, error) { curr, err := currency.GetCurrencyByCode(currencyCode) if err != nil { return nil, errors.New("invalid currency code") } - return History(userID, curr.ID, limit) + return HistoryWithTenant(userID, tenantID, curr.ID, limit) } // HistoryAllByUser returns last n transactions across all wallets owned by the user. func HistoryAllByUser(userID uint, limit int) ([]model.WalletTx, error) { + return HistoryAllByUserWithTenant(userID, 0, limit) +} + +func HistoryAllByUserWithTenant(userID uint, tenantID uint, limit int) ([]model.WalletTx, error) { if limit <= 0 { limit = 20 } db := common.DB() - tenantID, err := resolveUserTenantID(db, userID) + effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID) if err != nil { return nil, err } var walletIDs []uint if err := db.Model(&model.Wallet{}). - Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Where("user_id = ? AND tenant_id = ?", userID, effectiveTenantID). Pluck("id", &walletIDs).Error; err != nil { return nil, err } From 38d19833a465f4e51f6593d8bb4f4e39f1a5bb92 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 02:48:49 -0700 Subject: [PATCH 03/21] test(tenant): add wallet and order tenant-isolation service tests --- .../service/order/service_tenant_test.go | 150 ++++++++++++++++++ .../service/wallet/service_tenant_test.go | 96 +++++++++++ 2 files changed, 246 insertions(+) create mode 100644 basaltpass-backend/internal/service/order/service_tenant_test.go create mode 100644 basaltpass-backend/internal/service/wallet/service_tenant_test.go diff --git a/basaltpass-backend/internal/service/order/service_tenant_test.go b/basaltpass-backend/internal/service/order/service_tenant_test.go new file mode 100644 index 00000000..ea76c295 --- /dev/null +++ b/basaltpass-backend/internal/service/order/service_tenant_test.go @@ -0,0 +1,150 @@ +package order + +import ( + "testing" + "time" + + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupOrderServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + + if err := db.AutoMigrate( + &model.User{}, + &model.Product{}, + &model.Plan{}, + &model.Price{}, + &model.Coupon{}, + &model.PaymentIntent{}, + &model.PaymentSession{}, + &model.Order{}, + ); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + common.SetDBForTest(db) + return db +} + +func createPriceForTenant(t *testing.T, db *gorm.DB, tenantID uint64, codeSuffix string, amount int64) model.Price { + t.Helper() + + product := model.Product{ + TenantID: &tenantID, + Code: "prod-" + codeSuffix, + Name: "Product " + codeSuffix, + IsActive: true, + } + if err := db.Create(&product).Error; err != nil { + t.Fatalf("create product failed: %v", err) + } + + plan := model.Plan{ + TenantID: &tenantID, + ProductID: product.ID, + Code: "plan-" + codeSuffix, + DisplayName: "Plan " + codeSuffix, + } + if err := db.Create(&plan).Error; err != nil { + t.Fatalf("create plan failed: %v", err) + } + + price := model.Price{ + TenantID: &tenantID, + PlanID: plan.ID, + Currency: "USD", + AmountCents: amount, + BillingPeriod: model.BillingPeriodMonth, + BillingInterval: 1, + UsageType: model.UsageTypeLicense, + } + if err := db.Create(&price).Error; err != nil { + t.Fatalf("create price failed: %v", err) + } + + return price +} + +func TestOrderServiceTenantIsolation(t *testing.T) { + db := setupOrderServiceTestDB(t) + + user := model.User{TenantID: 0, Email: "order-user@example.com", PasswordHash: "x"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + tenantA := uint64(111) + tenantB := uint64(222) + priceA := createPriceForTenant(t, db, tenantA, "a", 1000) + priceB := createPriceForTenant(t, db, tenantB, "b", 2000) + + orderA := model.Order{ + OrderNumber: "ORD-TENANT-A", + UserID: user.ID, + PriceID: priceA.ID, + Status: model.OrderStatusPending, + Quantity: 1, + BaseAmount: 1000, + DiscountAmount: 0, + TotalAmount: 1000, + Currency: "USD", + Description: "tenant-a-order", + ExpiresAt: time.Now().Add(30 * time.Minute), + } + orderB := model.Order{ + OrderNumber: "ORD-TENANT-B", + UserID: user.ID, + PriceID: priceB.ID, + Status: model.OrderStatusPending, + Quantity: 1, + BaseAmount: 2000, + DiscountAmount: 0, + TotalAmount: 2000, + Currency: "USD", + Description: "tenant-b-order", + ExpiresAt: time.Now().Add(30 * time.Minute), + } + if err := db.Create(&orderA).Error; err != nil { + t.Fatalf("create order A failed: %v", err) + } + if err := db.Create(&orderB).Error; err != nil { + t.Fatalf("create order B failed: %v", err) + } + + svc := NewOrderService(db) + + orders, err := svc.ListUserOrders(user.ID, 20, tenantA) + if err != nil { + t.Fatalf("list orders failed: %v", err) + } + if len(orders) != 1 { + t.Fatalf("expected 1 order under tenant A, got %d", len(orders)) + } + if orders[0].OrderNumber != orderA.OrderNumber { + t.Fatalf("expected tenant A order %s, got %s", orderA.OrderNumber, orders[0].OrderNumber) + } + + if _, err := svc.GetOrder(user.ID, orderB.ID, false, tenantA); err == nil { + t.Fatalf("expected tenant-isolated get to reject tenant B order under tenant A context") + } + + got, err := svc.GetOrder(user.ID, orderA.ID, false, tenantA) + if err != nil { + t.Fatalf("expected get order in matching tenant to succeed, got %v", err) + } + if got.OrderNumber != orderA.OrderNumber { + t.Fatalf("unexpected order returned: %s", got.OrderNumber) + } +} diff --git a/basaltpass-backend/internal/service/wallet/service_tenant_test.go b/basaltpass-backend/internal/service/wallet/service_tenant_test.go new file mode 100644 index 00000000..276d356c --- /dev/null +++ b/basaltpass-backend/internal/service/wallet/service_tenant_test.go @@ -0,0 +1,96 @@ +package wallet + +import ( + "testing" + + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupWalletServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.TenantUser{}, &model.Currency{}, &model.Wallet{}, &model.WalletTx{}); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + common.SetDBForTest(db) + return db +} + +func TestWalletTenantIsolationByActiveTenant(t *testing.T) { + db := setupWalletServiceTestDB(t) + + curr := model.Currency{Code: "USD", Name: "US Dollar", IsActive: true, DecimalPlaces: 2} + if err := db.Create(&curr).Error; err != nil { + t.Fatalf("create currency failed: %v", err) + } + + user := model.User{TenantID: 0, Email: "wallet-user@example.com", PasswordHash: "x"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + tenantA := uint(101) + tenantB := uint(202) + memberships := []model.TenantUser{ + {UserID: user.ID, TenantID: tenantA, Role: model.TenantRoleMember}, + {UserID: user.ID, TenantID: tenantB, Role: model.TenantRoleMember}, + } + if err := db.Create(&memberships).Error; err != nil { + t.Fatalf("create memberships failed: %v", err) + } + + walletA := model.Wallet{TenantID: tenantA, UserID: &user.ID, CurrencyID: &curr.ID, Balance: 1000} + walletB := model.Wallet{TenantID: tenantB, UserID: &user.ID, CurrencyID: &curr.ID, Balance: 3000} + if err := db.Create(&walletA).Error; err != nil { + t.Fatalf("create tenant A wallet failed: %v", err) + } + if err := db.Create(&walletB).Error; err != nil { + t.Fatalf("create tenant B wallet failed: %v", err) + } + + wa, err := GetBalanceByCodeWithTenant(user.ID, tenantA, "USD") + if err != nil { + t.Fatalf("get tenant A balance failed: %v", err) + } + if wa.TenantID != tenantA || wa.Balance != 1000 { + t.Fatalf("unexpected tenant A wallet: tenant=%d balance=%d", wa.TenantID, wa.Balance) + } + + wb, err := GetBalanceByCodeWithTenant(user.ID, tenantB, "USD") + if err != nil { + t.Fatalf("get tenant B balance failed: %v", err) + } + if wb.TenantID != tenantB || wb.Balance != 3000 { + t.Fatalf("unexpected tenant B wallet: tenant=%d balance=%d", wb.TenantID, wb.Balance) + } + + if err := RechargeByCodeWithTenant(user.ID, tenantA, "USD", 250); err != nil { + t.Fatalf("recharge tenant A wallet failed: %v", err) + } + + var afterA model.Wallet + if err := db.Where("id = ?", walletA.ID).First(&afterA).Error; err != nil { + t.Fatalf("reload tenant A wallet failed: %v", err) + } + if afterA.Balance != 1250 { + t.Fatalf("expected tenant A balance 1250, got %d", afterA.Balance) + } + + var afterB model.Wallet + if err := db.Where("id = ?", walletB.ID).First(&afterB).Error; err != nil { + t.Fatalf("reload tenant B wallet failed: %v", err) + } + if afterB.Balance != 3000 { + t.Fatalf("tenant B balance changed unexpectedly: %d", afterB.Balance) + } +} From 402643ca6f29ba9f5c0b832a7504f25c8dc6bb1a Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 11:55:30 -0700 Subject: [PATCH 04/21] feat(auth): support global user tenant perspective switching --- .../internal/handler/public/auth/console.go | 30 +++--- .../handler/public/auth/console_test.go | 95 +++++++++++++++++++ .../internal/service/auth/service.go | 4 - .../internal/service/auth/service_test.go | 47 ++++++++- .../src/features/user/components/Layout.tsx | 69 +++++++++++++- .../src/shared/contexts/AuthContext.tsx | 9 +- .../src/shared/i18n/messages/en.ts | 5 + .../src/shared/i18n/messages/zh.ts | 5 + 8 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 basaltpass-backend/internal/handler/public/auth/console_test.go diff --git a/basaltpass-backend/internal/handler/public/auth/console.go b/basaltpass-backend/internal/handler/public/auth/console.go index 0e05dd9c..a5caf4d7 100644 --- a/basaltpass-backend/internal/handler/public/auth/console.go +++ b/basaltpass-backend/internal/handler/public/auth/console.go @@ -15,6 +15,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" ) type consoleCodeState struct { @@ -126,14 +127,6 @@ func userHasTenantAdminAccess(userID uint, tenantID uint) (bool, error) { return false, nil } - var user model.User - if err := common.DB().Select("id", "tenant_id").First(&user, userID).Error; err != nil { - return false, err - } - if user.TenantID != tenantID { - return false, nil - } - var cnt int64 err := common.DB().Model(&model.TenantUser{}). Where("user_id = ? AND tenant_id = ? AND role IN ?", userID, tenantID, []model.TenantRole{model.TenantRoleOwner, model.TenantRoleAdmin}). @@ -145,21 +138,22 @@ func userHasTenantAdminAccess(userID uint, tenantID uint) (bool, error) { } func userDefaultTenantIDForAdminConsole(userID uint) (uint, error) { - var user model.User - if err := common.DB().Select("id", "tenant_id").First(&user, userID).Error; err != nil { - return 0, err - } - if user.TenantID == 0 { - return 0, errors.New("tenant admin access required") - } - ok, err := userHasTenantAdminAccess(userID, user.TenantID) + var membership model.TenantUser + err := common.DB(). + Where("user_id = ? AND role IN ?", userID, []model.TenantRole{model.TenantRoleOwner, model.TenantRoleAdmin}). + Order("created_at ASC"). + First(&membership).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, errors.New("tenant admin access required") + } return 0, err } - if !ok { + if membership.TenantID == 0 { return 0, errors.New("tenant admin access required") } - return user.TenantID, nil + + return membership.TenantID, nil } func mustGetUserID(c *fiber.Ctx) (uint, error) { diff --git a/basaltpass-backend/internal/handler/public/auth/console_test.go b/basaltpass-backend/internal/handler/public/auth/console_test.go new file mode 100644 index 00000000..d44157c6 --- /dev/null +++ b/basaltpass-backend/internal/handler/public/auth/console_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "testing" + "time" + + "basaltpass-backend/internal/common" + "basaltpass-backend/internal/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupConsoleAuthTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + common.SetDBForTest(db) + return db +} + +func TestUserHasTenantAdminAccess_GlobalUserUsesTenantUserRole(t *testing.T) { + db := setupConsoleAuthTestDB(t) + + tenant := model.Tenant{Name: "Acme", Code: "acme", Status: model.TenantStatusActive} + if err := db.Create(&tenant).Error; err != nil { + t.Fatalf("create tenant failed: %v", err) + } + + globalUser := model.User{Email: "global@example.com", PasswordHash: "x", TenantID: 0} + if err := db.Create(&globalUser).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + if err := db.Create(&model.TenantUser{ + UserID: globalUser.ID, + TenantID: tenant.ID, + Role: model.TenantRoleAdmin, + }).Error; err != nil { + t.Fatalf("create tenant_user failed: %v", err) + } + + ok, err := userHasTenantAdminAccess(globalUser.ID, tenant.ID) + if err != nil { + t.Fatalf("userHasTenantAdminAccess failed: %v", err) + } + if !ok { + t.Fatalf("expected admin access via tenant_user for global user") + } +} + +func TestUserDefaultTenantIDForAdminConsole_UsesEarliestAdminMembership(t *testing.T) { + db := setupConsoleAuthTestDB(t) + + tenantA := model.Tenant{Name: "Tenant A", Code: "tenant-a", Status: model.TenantStatusActive} + tenantB := model.Tenant{Name: "Tenant B", Code: "tenant-b", Status: model.TenantStatusActive} + if err := db.Create(&tenantA).Error; err != nil { + t.Fatalf("create tenant A failed: %v", err) + } + if err := db.Create(&tenantB).Error; err != nil { + t.Fatalf("create tenant B failed: %v", err) + } + + globalUser := model.User{Email: "multi@example.com", PasswordHash: "x", TenantID: 0} + if err := db.Create(&globalUser).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + base := time.Now().UTC().Add(-2 * time.Hour) + memberships := []model.TenantUser{ + {UserID: globalUser.ID, TenantID: tenantA.ID, Role: model.TenantRoleOwner, CreatedAt: base}, + {UserID: globalUser.ID, TenantID: tenantB.ID, Role: model.TenantRoleAdmin, CreatedAt: base.Add(time.Hour)}, + } + for _, membership := range memberships { + if err := db.Create(&membership).Error; err != nil { + t.Fatalf("create tenant_user failed: %v", err) + } + } + + tenantID, err := userDefaultTenantIDForAdminConsole(globalUser.ID) + if err != nil { + t.Fatalf("userDefaultTenantIDForAdminConsole failed: %v", err) + } + if tenantID != tenantA.ID { + t.Fatalf("expected earliest admin membership tenant id %d, got %d", tenantA.ID, tenantID) + } +} diff --git a/basaltpass-backend/internal/service/auth/service.go b/basaltpass-backend/internal/service/auth/service.go index 24def055..bdd560ed 100644 --- a/basaltpass-backend/internal/service/auth/service.go +++ b/basaltpass-backend/internal/service/auth/service.go @@ -226,10 +226,6 @@ func (s Service) LoginV2(req LoginRequest) (LoginResult, error) { } return LoginResult{}, normalizeLoginQueryError(err) } - - if req.Scope == ConsoleScopeAdmin && !user.IsSuperAdmin() { - return LoginResult{}, ErrPlatformAdminOnly - } } else { // 租户登录:查询指定租户下的用户 query = query.Where("tenant_id = ?", req.TenantID) diff --git a/basaltpass-backend/internal/service/auth/service_test.go b/basaltpass-backend/internal/service/auth/service_test.go index 4829c845..2e9e65c0 100644 --- a/basaltpass-backend/internal/service/auth/service_test.go +++ b/basaltpass-backend/internal/service/auth/service_test.go @@ -9,6 +9,7 @@ import ( "basaltpass-backend/internal/model" "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -26,13 +27,14 @@ func TestGenerateTokenPair(t *testing.T) { func setupAuthLoginTestDB(t *testing.T) *gorm.DB { t.Helper() + os.Setenv("JWT_SECRET", "test-secret-for-unit-tests") db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite failed: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.Passkey{}, &model.TenantUser{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.Passkey{}, &model.TenantUser{}, &model.UserTenantTOTP{}); err != nil { t.Fatalf("auto migrate failed: %v", err) } @@ -102,3 +104,46 @@ func TestLoginV2GlobalPortalAllowsGlobalAccount(t *testing.T) { t.Fatalf("expected user id %d, got %d", user.ID, res.UserID) } } + +func TestLoginV2GlobalPortalAllowsRegularUserInAdminScope(t *testing.T) { + db := setupAuthLoginTestDB(t) + + user := model.User{ + TenantID: 0, + Email: "regular-admin-scope@example.com", + PasswordHash: mustPasswordHash(t, "pass-789"), + Nickname: "regular-user", + EmailVerified: true, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + res, err := Service{}.LoginV2(LoginRequest{ + EmailOrPhone: user.Email, + Password: "pass-789", + TenantID: 0, + Scope: ConsoleScopeAdmin, + }) + if err != nil { + t.Fatalf("admin-scope login for regular global user should succeed, got error: %v", err) + } + if res.UserID != user.ID { + t.Fatalf("expected user id %d, got %d", user.ID, res.UserID) + } + + accessToken, parseErr := ParseToken(res.AccessToken) + if parseErr != nil || accessToken == nil || !accessToken.Valid { + t.Fatalf("access token invalid: %v", parseErr) + } + claims, ok := accessToken.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("unexpected claims type: %T", accessToken.Claims) + } + if scope, _ := claims["scp"].(string); scope != ConsoleScopeAdmin { + t.Fatalf("expected scope %q, got %q", ConsoleScopeAdmin, scope) + } + if tenantID, _ := claims["tid"].(float64); uint(tenantID) != 0 { + t.Fatalf("expected tenant id 0, got %v", claims["tid"]) + } +} diff --git a/basaltpass-frontend/src/features/user/components/Layout.tsx b/basaltpass-frontend/src/features/user/components/Layout.tsx index 1e525ff6..d7509890 100644 --- a/basaltpass-frontend/src/features/user/components/Layout.tsx +++ b/basaltpass-frontend/src/features/user/components/Layout.tsx @@ -19,7 +19,7 @@ import { ChevronDownIcon, ArrowRightOnRectangleIcon } from '@heroicons/react/24/outline' -import { PButton } from '@ui' +import { Modal, PButton } from '@ui' import { useAuth } from '@contexts/AuthContext' import { useConfig } from '@contexts/ConfigContext' import EnhancedNotificationIcon from '@components/EnhancedNotificationIcon' @@ -37,6 +37,8 @@ export default function Layout({ children }: LayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false) const [isUserMenuOpen, setIsUserMenuOpen] = useState(false) const [showAccountSwitcher, setShowAccountSwitcher] = useState(false) + const [showTenantPerspectivePicker, setShowTenantPerspectivePicker] = useState(false) + const [switchingTenantId, setSwitchingTenantId] = useState(null) const desktopUserMenuRef = useRef(null) const mobileUserMenuRef = useRef(null) const location = useLocation() @@ -54,6 +56,12 @@ export default function Layout({ children }: LayoutProps) { return siteName })() + const manageableTenants = tenants.filter((tenant) => { + const roleFromMetadata = String((tenant as any)?.metadata?.user_role || '').toLowerCase() + const role = roleFromMetadata || String(tenant?.role || '').toLowerCase() + return Number(tenant?.id || 0) > 0 && ['owner', 'admin'].includes(role) + }) + const navigation = [ { name: t('userLayout.nav.dashboard'), href: ROUTES.user.dashboard, icon: HomeIcon }, { name: t('userLayout.nav.profile'), href: ROUTES.user.profile, icon: UserIcon }, @@ -138,15 +146,31 @@ export default function Layout({ children }: LayoutProps) { return `${b}/${p}` } - const switchToTenant = async () => { + const switchToTenantById = async (tenantId: number) => { + setSwitchingTenantId(tenantId) try { - const { code } = await authorizeConsole('tenant') + const { code } = await authorizeConsole('tenant', tenantId) const url = joinUrl(consoleTenantUrl, `tenant/dashboard?code=${encodeURIComponent(code)}`) window.location.href = url } catch (error: any) { const message = error?.response?.data?.error || t('userLayout.tenantSwitchFailed') await uiAlert(message, t('userLayout.tenantSwitchFailedTitle')) + setSwitchingTenantId(null) + } + } + + const switchToTenant = async () => { + if (manageableTenants.length === 0) { + await uiAlert(t('userLayout.noTenantPerspectiveAvailable'), t('userLayout.tenantSwitchFailedTitle')) + return + } + + if (manageableTenants.length === 1) { + await switchToTenantById(Number(manageableTenants[0].id || 0)) + return } + + setShowTenantPerspectivePicker(true) } const switchToAdmin = async () => { @@ -444,6 +468,45 @@ export default function Layout({ children }: LayoutProps) { consoleAdminUrl={consoleAdminUrl} /> + { + if (!switchingTenantId) { + setShowTenantPerspectivePicker(false) + } + }} + title={t('userLayout.selectTenantPerspectiveTitle')} + description={t('userLayout.selectTenantPerspectiveDescription')} + widthClass="max-w-xl" + > +
+ {manageableTenants.map((tenant) => { + const roleFromMetadata = String((tenant as any)?.metadata?.user_role || '').toLowerCase() + const role = roleFromMetadata || String(tenant?.role || '').toLowerCase() + const tenantName = tenant?.name || t('userLayout.tenantUnknown') + const tenantId = Number(tenant?.id || 0) + + return ( +
+
+
{tenantName}
+
ID: {tenantId} {role ? `· ${role}` : ''}
+
+ void switchToTenantById(tenantId)} + > + {t('userLayout.switchToTenantFor', { tenant: tenantName })} + +
+ ) + })} +
+
+ {/* , */}
) diff --git a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx index 066f417d..8eda102e 100644 --- a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx +++ b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx @@ -33,6 +33,7 @@ interface UserTenant { name?: string code?: string role?: string + metadata?: Record status?: string } @@ -324,7 +325,13 @@ export function AuthProvider({ children }: AuthProviderProps) { }, [user, isLoading, hasChecked]) const tenantRole = (user?.tenant_role || '').toLowerCase() - const canManageTenant = user?.tenant_id ? user.tenant_id > 0 && ['owner', 'admin'].includes(tenantRole) : false + const canManageCurrentTenant = user?.tenant_id ? user.tenant_id > 0 && ['owner', 'admin'].includes(tenantRole) : false + const canManageAnyTenant = tenants.some((tenant) => { + const roleFromMetadata = String(tenant?.metadata?.user_role || '').toLowerCase() + const role = roleFromMetadata || String(tenant?.role || '').toLowerCase() + return Number(tenant?.id || 0) > 0 && ['owner', 'admin'].includes(role) + }) + const canManageTenant = canManageCurrentTenant || canManageAnyTenant const value: AuthContextType = { user, diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index 5c392107..5e30b792 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -1455,6 +1455,11 @@ const en = { switchToAdminTitle: 'Switch to Global Admin Console (on-demand authorization)', tenantSwitchFailed: 'This account has no tenant management permission, or tenant console authorization failed.', tenantSwitchFailedTitle: 'Unable to Open Tenant Management', + noTenantPerspectiveAvailable: 'No tenant perspective is available for this account.', + selectTenantPerspectiveTitle: 'Select Tenant Perspective', + selectTenantPerspectiveDescription: 'Choose a tenant to enter its dashboard view.', + switchToTenantFor: 'Enter {{tenant}}', + tenantUnknown: 'Unknown tenant', }, tenantLayout: { consoleTitle: 'Tenant Console', diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index 99301889..db4fea1e 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -1455,6 +1455,11 @@ const en = { switchToAdminTitle: 'Switch to Global Admin Console (on-demand authorization)', tenantSwitchFailed: 'This account has no tenant management permission, or tenant console authorization failed.', tenantSwitchFailedTitle: 'Unable to Open Tenant Management', + noTenantPerspectiveAvailable: 'No tenant perspective is available for this account.', + selectTenantPerspectiveTitle: 'Select Tenant Perspective', + selectTenantPerspectiveDescription: 'Choose a tenant to enter its dashboard view.', + switchToTenantFor: 'Enter {{tenant}}', + tenantUnknown: 'Unknown tenant', }, tenantLayout: { consoleTitle: 'Tenant Console', From 8e5b3b2fdc06ff7657f682e0f8ac7af4d9bbbba5 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 12:12:15 -0700 Subject: [PATCH 05/21] fix: hide wallet for users without tenant identity --- .../internal/handler/user/wallet_handler.go | 12 ++ .../internal/service/wallet/service.go | 9 +- .../service/wallet/service_tenant_test.go | 28 ++++- basaltpass-frontend/apps/user/src/router.tsx | 10 +- .../src/features/user/Dashboard.tsx | 105 ++++++++++-------- .../src/features/user/components/Layout.tsx | 5 +- .../src/shared/contexts/AuthContext.tsx | 3 + .../src/shared/routes/ProtectedRoute.tsx | 10 +- 8 files changed, 119 insertions(+), 63 deletions(-) diff --git a/basaltpass-backend/internal/handler/user/wallet_handler.go b/basaltpass-backend/internal/handler/user/wallet_handler.go index e86c8d50..6d28e9df 100644 --- a/basaltpass-backend/internal/handler/user/wallet_handler.go +++ b/basaltpass-backend/internal/handler/user/wallet_handler.go @@ -17,6 +17,9 @@ func GetWalletBalanceHandler(c *fiber.Ctx) error { currency := c.Query("currency", "USD") w, err := wallet.GetBalanceByCodeWithTenant(uid, activeTenantID, currency) if err != nil { + if errors.Is(err, wallet.ErrNoTenantIdentity) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "wallet is unavailable for users without tenant"}) + } return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"balance": w.Balance, "currency_id": w.CurrencyID, "tenant_id": w.TenantID}) @@ -34,6 +37,9 @@ func RechargeWalletHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } if err := wallet.RechargeByCodeWithTenant(uid, activeTenantID, body.Currency, body.Amount); err != nil { + if errors.Is(err, wallet.ErrNoTenantIdentity) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "wallet is unavailable for users without tenant"}) + } if errors.Is(err, wallet.ErrWalletRechargeWithdrawDisabled) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -54,6 +60,9 @@ func WithdrawWalletHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } if err := wallet.WithdrawByCodeWithTenant(uid, activeTenantID, body.Currency, body.Amount); err != nil { + if errors.Is(err, wallet.ErrNoTenantIdentity) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "wallet is unavailable for users without tenant"}) + } if errors.Is(err, wallet.ErrWalletRechargeWithdrawDisabled) { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) } @@ -77,6 +86,9 @@ func WalletHistoryHandler(c *fiber.Ctx) error { txs, err = wallet.HistoryByCodeWithTenant(uid, activeTenantID, currency, limit) } if err != nil { + if errors.Is(err, wallet.ErrNoTenantIdentity) { + return c.JSON([]interface{}{}) + } return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(txs) diff --git a/basaltpass-backend/internal/service/wallet/service.go b/basaltpass-backend/internal/service/wallet/service.go index 8f9a08a5..a6e7c461 100644 --- a/basaltpass-backend/internal/service/wallet/service.go +++ b/basaltpass-backend/internal/service/wallet/service.go @@ -17,6 +17,11 @@ const ( fallbackCreditCode2 = "USD" ) +var ( + ErrNoTenantIdentity = errors.New("user has no tenant identity") + ErrUserNotInTenantContext = errors.New("user does not belong to requested tenant") +) + func resolveCreditCurrency(tx *gorm.DB) (model.Currency, error) { var curr model.Currency if err := tx.Where("code = ? AND is_active = ?", CreditCurrencyCode, true).First(&curr).Error; err == nil { @@ -53,7 +58,7 @@ func resolveEffectiveTenantID(tx *gorm.DB, userID uint, requestedTenantID uint) var membership model.TenantUser if err := tx.Select("tenant_id").Where("user_id = ?", userID).Order("created_at ASC").First(&membership).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, errors.New("user has no tenant identity") + return 0, ErrNoTenantIdentity } return 0, err } @@ -71,7 +76,7 @@ func resolveEffectiveTenantID(tx *gorm.DB, userID uint, requestedTenantID uint) return 0, err } if membershipCount == 0 { - return 0, errors.New("user does not belong to requested tenant") + return 0, ErrUserNotInTenantContext } return requestedTenantID, nil diff --git a/basaltpass-backend/internal/service/wallet/service_tenant_test.go b/basaltpass-backend/internal/service/wallet/service_tenant_test.go index 276d356c..5146f27b 100644 --- a/basaltpass-backend/internal/service/wallet/service_tenant_test.go +++ b/basaltpass-backend/internal/service/wallet/service_tenant_test.go @@ -1,6 +1,9 @@ package wallet import ( + "errors" + "fmt" + "strings" "testing" "basaltpass-backend/internal/common" @@ -13,7 +16,8 @@ import ( func setupWalletServiceTestDB(t *testing.T) *gorm.DB { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite failed: %v", err) } @@ -74,8 +78,8 @@ func TestWalletTenantIsolationByActiveTenant(t *testing.T) { t.Fatalf("unexpected tenant B wallet: tenant=%d balance=%d", wb.TenantID, wb.Balance) } - if err := RechargeByCodeWithTenant(user.ID, tenantA, "USD", 250); err != nil { - t.Fatalf("recharge tenant A wallet failed: %v", err) + if _, err := AdjustByCodeWithTenant(user.ID, tenantA, "USD", 250, "test_adjust", ""); err != nil { + t.Fatalf("adjust tenant A wallet failed: %v", err) } var afterA model.Wallet @@ -94,3 +98,21 @@ func TestWalletTenantIsolationByActiveTenant(t *testing.T) { t.Fatalf("tenant B balance changed unexpectedly: %d", afterB.Balance) } } + +func TestWalletRequiresTenantIdentity(t *testing.T) { + db := setupWalletServiceTestDB(t) + + curr := model.Currency{Code: "USD", Name: "US Dollar", IsActive: true, DecimalPlaces: 2} + if err := db.Create(&curr).Error; err != nil { + t.Fatalf("create currency failed: %v", err) + } + + user := model.User{TenantID: 0, Email: "wallet-no-tenant@example.com", PasswordHash: "x"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + if _, err := GetBalanceByCodeWithTenant(user.ID, 0, "USD"); !errors.Is(err, ErrNoTenantIdentity) { + t.Fatalf("expected ErrNoTenantIdentity, got %v", err) + } +} diff --git a/basaltpass-frontend/apps/user/src/router.tsx b/basaltpass-frontend/apps/user/src/router.tsx index 055c209b..41ea5220 100644 --- a/basaltpass-frontend/apps/user/src/router.tsx +++ b/basaltpass-frontend/apps/user/src/router.tsx @@ -181,11 +181,11 @@ export default function AppRouter() { } /> {/* Wallet */} - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> {/* Security */} diff --git a/basaltpass-frontend/src/features/user/Dashboard.tsx b/basaltpass-frontend/src/features/user/Dashboard.tsx index 35091960..f2459586 100644 --- a/basaltpass-frontend/src/features/user/Dashboard.tsx +++ b/basaltpass-frontend/src/features/user/Dashboard.tsx @@ -6,21 +6,18 @@ import { history as getWalletHistory } from '@api/user/wallet' import { getSecurityStatus, SecurityStatus } from '@api/user/security' import { getProfile, UserBasicProfile } from '@api/user/profile' import { ROUTES } from '@constants' +import { useAuth } from '@contexts/AuthContext' import { useConfig } from '@contexts/ConfigContext' import { useI18n } from '@shared/i18n' import { - WalletIcon, UserIcon, ShieldCheckIcon, - ChartBarIcon, ArrowUpIcon, ArrowDownIcon, - CurrencyDollarIcon, ClockIcon, UserGroupIcon, CogIcon, BellIcon, - DocumentTextIcon, CreditCardIcon, ShoppingCartIcon, QuestionMarkCircleIcon @@ -52,6 +49,7 @@ interface WalletHistoryItem { export default function Dashboard() { const { marketEnabled, walletRechargeWithdrawEnabled } = useConfig() + const { canUseWallet } = useAuth() const { t, locale } = useI18n() const [userProfile, setUserProfile] = useState(null) const [securityStatus, setSecurityStatus] = useState(null) @@ -66,41 +64,46 @@ export default function Dashboard() { setError(null) // 、 - const [profileResponse, securityResponse, historyResponse] = await Promise.all([ + const [profileResponse, securityResponse] = await Promise.all([ getProfile(), getSecurityStatus(), - getWalletHistory(undefined, 3) // 3() ]) setUserProfile(profileResponse.data) setSecurityStatus(securityResponse.data) - - const historyItems = Array.isArray(historyResponse.data) - ? historyResponse.data - : Array.isArray(historyResponse.data?.transactions) - ? historyResponse.data.transactions - : [] - setRecentTransactions( - historyItems.map((transaction: WalletHistoryItem) => ({ - id: String(transaction.ID ?? crypto.randomUUID()), - type: transaction.Type === 'withdraw' ? 'withdraw' : 'recharge', - amount: Math.abs(Number(transaction.Amount ?? 0)), - status: - transaction.Status === 'success' - ? 'completed' - : transaction.Status === 'fail' - ? 'failed' - : 'pending', - date: transaction.CreatedAt ?? new Date().toISOString(), - description: - transaction.Reference?.trim() - ? transaction.Reference - : transaction.Type === 'withdraw' - ? t('pages.dashboard.recentTransactions.withdrawFallback') - : t('pages.dashboard.recentTransactions.rechargeFallback'), - })) - ) + const hasTenantIdentity = Boolean(profileResponse.data?.has_tenant) || Number(profileResponse.data?.tenant_id || 0) > 0 + if (hasTenantIdentity) { + const historyResponse = await getWalletHistory(undefined, 3) // 3() + const historyItems = Array.isArray(historyResponse.data) + ? historyResponse.data + : Array.isArray(historyResponse.data?.transactions) + ? historyResponse.data.transactions + : [] + + setRecentTransactions( + historyItems.map((transaction: WalletHistoryItem) => ({ + id: String(transaction.ID ?? crypto.randomUUID()), + type: transaction.Type === 'withdraw' ? 'withdraw' : 'recharge', + amount: Math.abs(Number(transaction.Amount ?? 0)), + status: + transaction.Status === 'success' + ? 'completed' + : transaction.Status === 'fail' + ? 'failed' + : 'pending', + date: transaction.CreatedAt ?? new Date().toISOString(), + description: + transaction.Reference?.trim() + ? transaction.Reference + : transaction.Type === 'withdraw' + ? t('pages.dashboard.recentTransactions.withdrawFallback') + : t('pages.dashboard.recentTransactions.rechargeFallback'), + })) + ) + } else { + setRecentTransactions([]) + } } catch (err) { console.error('Failed to load dashboard data:', err) setError(t('pages.dashboard.errors.loadFailedMessage')) @@ -260,7 +263,7 @@ export default function Dashboard() { {t('pages.dashboard.quickActions.title')}
- {walletRechargeWithdrawEnabled ? ( + {canUseWallet && (walletRechargeWithdrawEnabled ? ( {t('pages.dashboard.quickActions.rechargeDisabledDesc')}

- )} + ))} - {walletRechargeWithdrawEnabled ? ( + {canUseWallet && (walletRechargeWithdrawEnabled ? ( {t('pages.dashboard.quickActions.withdrawDisabledDesc')}

- )} + ))} - -
- -
-
-
- + {canUseWallet && ( + +
+ +
+
+
+ + )} {/* */} + {canUseWallet && (

@@ -517,6 +523,7 @@ export default function Dashboard() {

+ )} ) diff --git a/basaltpass-frontend/src/features/user/components/Layout.tsx b/basaltpass-frontend/src/features/user/components/Layout.tsx index d7509890..c148ad3f 100644 --- a/basaltpass-frontend/src/features/user/components/Layout.tsx +++ b/basaltpass-frontend/src/features/user/components/Layout.tsx @@ -43,7 +43,7 @@ export default function Layout({ children }: LayoutProps) { const mobileUserMenuRef = useRef(null) const location = useLocation() const { t } = useI18n() - const { user, tenants, logout, canAccessTenant, canAccessAdmin } = useAuth() + const { user, tenants, logout, canAccessTenant, canAccessAdmin, canUseWallet } = useAuth() const { marketEnabled, siteName, siteInitial, setPageTitle } = useConfig() const currentSessionKey = `${user?.id || 0}:${Number(user?.tenant_id || 0)}` @@ -129,6 +129,9 @@ export default function Layout({ children }: LayoutProps) { // const filteredNavigation = navigation.filter(item => { + if (item.href === ROUTES.user.wallet && !canUseWallet) { + return false + } if (item.requiresMarket && !marketEnabled) { return false } diff --git a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx index 8eda102e..0982fd96 100644 --- a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx +++ b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx @@ -43,6 +43,7 @@ interface AuthContextType { userSessions: UserConsoleSession[] canAccessAdmin: boolean canAccessTenant: boolean + canUseWallet: boolean isAuthenticated: boolean isLoading: boolean login: (token: string) => Promise @@ -332,6 +333,7 @@ export function AuthProvider({ children }: AuthProviderProps) { return Number(tenant?.id || 0) > 0 && ['owner', 'admin'].includes(role) }) const canManageTenant = canManageCurrentTenant || canManageAnyTenant + const canUseWallet = !!user && (Boolean(user.has_tenant) || Number(user.tenant_id || 0) > 0 || tenants.length > 0) const value: AuthContextType = { user, @@ -339,6 +341,7 @@ export function AuthProvider({ children }: AuthProviderProps) { userSessions, canAccessAdmin: !!user?.is_super_admin, canAccessTenant: canManageTenant, + canUseWallet, isAuthenticated: !!user, isLoading, login, diff --git a/basaltpass-frontend/src/shared/routes/ProtectedRoute.tsx b/basaltpass-frontend/src/shared/routes/ProtectedRoute.tsx index 640de331..7a641c59 100644 --- a/basaltpass-frontend/src/shared/routes/ProtectedRoute.tsx +++ b/basaltpass-frontend/src/shared/routes/ProtectedRoute.tsx @@ -1,15 +1,16 @@ import { useEffect } from 'react' -import { useNavigate } from 'react-router-dom' +import { Navigate, useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' import { debugAuth } from '../utils/debug' import PSkeleton from '@ui/PSkeleton' interface ProtectedRouteProps { children: React.ReactNode + requiresTenant?: boolean } -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated, isLoading } = useAuth() +export default function ProtectedRoute({ children, requiresTenant = false }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, canUseWallet } = useAuth() const navigate = useNavigate() useEffect(() => { @@ -39,6 +40,9 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { // translatedalreadytranslated,translatedcomponent if (isAuthenticated) { + if (requiresTenant && !canUseWallet) { + return + } debugAuth.log('ProtectedRoute: showing protected content') return <>{children} } From f17de16650f61eeb071ec9f21b60238689f0a621 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 12:28:40 -0700 Subject: [PATCH 06/21] feat(tenant): add global user authorization flow --- .../internal/api/v1/routes/tenant.go | 2 + .../internal/handler/tenant/user_handler.go | 186 +++++++++++++++ .../apps/tenant/src/router.tsx | 2 + .../tenant/components/TenantNavigation.tsx | 4 +- .../tenant/user/GlobalUserAuthorization.tsx | 212 ++++++++++++++++++ .../shared/api/tenant/tenantUserManagement.ts | 37 +++ .../src/shared/constants/routes/tenant.ts | 1 + .../src/shared/i18n/messages/en.ts | 41 ++++ .../src/shared/i18n/messages/zh.ts | 41 ++++ 9 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 basaltpass-frontend/src/features/tenant/user/GlobalUserAuthorization.tsx diff --git a/basaltpass-backend/internal/api/v1/routes/tenant.go b/basaltpass-backend/internal/api/v1/routes/tenant.go index 2c4abb78..3a23e7b0 100644 --- a/basaltpass-backend/internal/api/v1/routes/tenant.go +++ b/basaltpass-backend/internal/api/v1/routes/tenant.go @@ -55,6 +55,8 @@ func RegisterTenantRoutes(v1 fiber.Router) { tenantUserGroup.Get("/", tenant2.GetTenantUsersHandler) // 获取tenant的全部app的全部用户 tenantUserGroup.Get("/app-linked", tenant2.GetTenantAppLinkedUsersHandler) tenantUserGroup.Get("/stats", tenant2.GetTenantUserStatsHandler) + tenantUserGroup.Get("/global-candidates", tenant2.GetGlobalUserCandidatesHandler) + tenantUserGroup.Post("/global-candidates/:id/authorize", tenant2.AuthorizeGlobalUserToTenantHandler) tenantUserGroup.Get("/:id", tenant2.GetTenantUserHandler) tenantUserGroup.Put("/:id", tenant2.UpdateTenantUserHandler) tenantUserGroup.Delete("/:id", tenant2.RemoveTenantUserHandler) diff --git a/basaltpass-backend/internal/handler/tenant/user_handler.go b/basaltpass-backend/internal/handler/tenant/user_handler.go index 388a9e76..91828beb 100644 --- a/basaltpass-backend/internal/handler/tenant/user_handler.go +++ b/basaltpass-backend/internal/handler/tenant/user_handler.go @@ -55,6 +55,20 @@ type InviteTenantUserRequest struct { Message string `json:"message,omitempty"` } +// GlobalUserCandidateResponse 全局用户候选响应 +type GlobalUserCandidateResponse struct { + ID uint `json:"id"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + CreatedAt time.Time `json:"created_at"` +} + +// AuthorizeGlobalUserRequest 授权全局用户加入当前租户 +type AuthorizeGlobalUserRequest struct { + Role string `json:"role,omitempty"` +} + func normalizeTenantInviteRole(raw string) (model.TenantRole, string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case string(model.TenantRoleAdmin): @@ -369,6 +383,178 @@ func GetTenantUserStatsHandler(c *fiber.Ctx) error { }) } +// GetGlobalUserCandidatesHandler 获取可授权加入当前租户的全局用户候选 +// GET /api/v1/tenant/users/global-candidates +func GetGlobalUserCandidatesHandler(c *fiber.Ctx) error { + if err := requireTenantAdminRole(c); err != nil { + return err + } + + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + search := strings.TrimSpace(c.Query("search", "")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + offset := (page - 1) * limit + + query := common.DB().Table("system_auth_users"). + Where("tenant_id = ?", 0). + Where("deleted_at IS NULL"). + Where("COALESCE(is_system_admin, 0) = 0") + + if search != "" { + query = query.Where("LOWER(email) LIKE LOWER(?) OR LOWER(nickname) LIKE LOWER(?)", "%"+search+"%", "%"+search+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "查询全局用户失败", + }) + } + + var users []GlobalUserCandidateResponse + if err := query.Select("id, email, nickname, COALESCE(avatar_url, '') as avatar, created_at"). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Scan(&users).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "查询全局用户失败", + }) + } + + return c.JSON(fiber.Map{ + "users": users, + "pagination": fiber.Map{ + "page": page, + "limit": limit, + "total": total, + }, + }) +} + +// AuthorizeGlobalUserToTenantHandler 授权全局用户加入当前租户 +// POST /api/v1/tenant/users/global-candidates/:id/authorize +func AuthorizeGlobalUserToTenantHandler(c *fiber.Ctx) error { + if err := requireTenantAdminRole(c); err != nil { + return err + } + + tenantID := c.Locals("tenantID").(uint) + userIDUint64, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "无效的用户ID", + }) + } + userID := uint(userIDUint64) + + var req AuthorizeGlobalUserRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "请求参数错误", + }) + } + + assignedRole := model.TenantRoleMember + if strings.TrimSpace(req.Role) != "" { + normalizedRole, _, ok := normalizeTenantInviteRole(req.Role) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "无效的角色类型", + }) + } + assignedRole = normalizedRole + } + + var user model.User + if err := common.DB().First(&user, userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "用户不存在", + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "查询用户失败", + }) + } + + if user.IsSuperAdmin() { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "不支持授权系统管理员加入租户", + }) + } + + if user.TenantID != 0 && user.TenantID != tenantID { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "error": "用户已属于其他租户", + }) + } + + tx := common.DB().Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if user.TenantID == 0 { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", tenantID).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "授权加入租户失败", + }) + } + } + + var tenantUser model.TenantUser + err = tx.Where("tenant_id = ? AND user_id = ?", tenantID, user.ID).First(&tenantUser).Error + if err != nil { + if err != gorm.ErrRecordNotFound { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "授权加入租户失败", + }) + } + + newTenantUser := model.TenantUser{ + UserID: user.ID, + TenantID: tenantID, + Role: assignedRole, + } + if err := tx.Create(&newTenantUser).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "授权加入租户失败", + }) + } + } else if strings.TrimSpace(req.Role) != "" && tenantUser.Role != model.TenantRoleOwner { + tenantUser.Role = assignedRole + if err := tx.Save(&tenantUser).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "授权加入租户失败", + }) + } + } + + if err := tx.Commit().Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "授权加入租户失败", + }) + } + + return c.JSON(fiber.Map{ + "message": "用户已加入当前租户", + }) +} + // UpdateTenantUserHandler 更新租户用户 // PUT /api/v1/tenant/users/:id func UpdateTenantUserHandler(c *fiber.Ctx) error { diff --git a/basaltpass-frontend/apps/tenant/src/router.tsx b/basaltpass-frontend/apps/tenant/src/router.tsx index 714c8a3a..80b354b2 100644 --- a/basaltpass-frontend/apps/tenant/src/router.tsx +++ b/basaltpass-frontend/apps/tenant/src/router.tsx @@ -14,6 +14,7 @@ import AppStats from '../../../src/features/tenant/app/AppStats' import TenantOAuthClients from '../../../src/features/tenant/app/OAuthClients' import TenantAutomationTokens from '../../../src/features/tenant/security/AutomationTokens' import TenantUserManagement from '../../../src/features/tenant/user/UserManagement' +import TenantGlobalUserAuthorization from '../../../src/features/tenant/user/GlobalUserAuthorization' import TenantTeamsPage from '../../../src/features/tenant/team/Teams' import TenantWalletManagement from '../../../src/features/tenant/wallet/WalletManagement' import GiftCardManagement from '../../../src/features/tenant/wallet/GiftCardManagement' @@ -98,6 +99,7 @@ export default function AppRouter() { } /> } /> + } /> } /> } /> } /> diff --git a/basaltpass-frontend/src/features/tenant/components/TenantNavigation.tsx b/basaltpass-frontend/src/features/tenant/components/TenantNavigation.tsx index 1c8c09d7..0062dae6 100644 --- a/basaltpass-frontend/src/features/tenant/components/TenantNavigation.tsx +++ b/basaltpass-frontend/src/features/tenant/components/TenantNavigation.tsx @@ -15,7 +15,8 @@ import { ShoppingCartIcon, InformationCircleIcon, RocketLaunchIcon, - CurrencyDollarIcon + CurrencyDollarIcon, + UserPlusIcon } from '@heroicons/react/24/outline' import { ROUTES } from '@constants' import { useConfig } from '@contexts/ConfigContext' @@ -56,6 +57,7 @@ const navigationItems: NavigationItem[] = [ icon: UsersIcon, children: [ { key: 'tenantNav.userList', href: ROUTES.tenant.users, icon: UsersIcon }, + { key: 'tenantNav.globalUserAuthorization', href: ROUTES.tenant.globalUserAuthorization, icon: UserPlusIcon }, { key: 'tenantNav.teamManagement', href: ROUTES.tenant.teams, icon: UsersIcon }, { key: 'tenantNav.walletManagement', href: ROUTES.tenant.wallets, icon: CurrencyDollarIcon }, { key: 'tenantNav.giftCardManagement', href: ROUTES.tenant.giftCards, icon: GiftIcon }, diff --git a/basaltpass-frontend/src/features/tenant/user/GlobalUserAuthorization.tsx b/basaltpass-frontend/src/features/tenant/user/GlobalUserAuthorization.tsx new file mode 100644 index 00000000..20a7781f --- /dev/null +++ b/basaltpass-frontend/src/features/tenant/user/GlobalUserAuthorization.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from 'react' +import { ExclamationTriangleIcon, MagnifyingGlassIcon, UserPlusIcon, UsersIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline' +import TenantLayout from '@features/tenant/components/TenantLayout' +import { PButton, PEmptyState, PInput, PPageHeader, PSkeleton } from '@ui' +import PTable from '@ui/PTable' +import { uiConfirm } from '@contexts/DialogContext' +import useDebounce from '@hooks/useDebounce' +import { tenantUserManagementApi, type GlobalUserCandidate } from '@api/tenant/tenantUserManagement' +import { useI18n } from '@shared/i18n' + +export default function TenantGlobalUserAuthorization() { + const { t, locale } = useI18n() + const [loading, setLoading] = useState(true) + const [submittingUserId, setSubmittingUserId] = useState(null) + const [users, setUsers] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [error, setError] = useState('') + const debouncedSearch = useDebounce(searchTerm, 250) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 20, + total: 0, + }) + + const fetchGlobalUsers = async (page = 1, pageSize = 20) => { + try { + setLoading(true) + setError('') + const response = await tenantUserManagementApi.getGlobalUserCandidates({ + page, + limit: pageSize, + search: debouncedSearch, + }) + setUsers(response.users || []) + setPagination({ + current: page, + pageSize, + total: response.pagination?.total || 0, + }) + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantGlobalUserAuthorization.errors.fetchFailed')) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchGlobalUsers(1, pagination.pageSize) + }, [debouncedSearch]) + + const handleAuthorize = async (user: GlobalUserCandidate) => { + const confirmed = await uiConfirm(t('tenantGlobalUserAuthorization.confirm.authorizeUser', { email: user.email })) + if (!confirmed) { + return + } + + try { + setSubmittingUserId(user.id) + await tenantUserManagementApi.authorizeGlobalUser(user.id) + await fetchGlobalUsers(pagination.current, pagination.pageSize) + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantGlobalUserAuthorization.errors.authorizeFailed')) + } finally { + setSubmittingUserId(null) + } + } + + if (loading) { + return ( + +
+ +
+
+ ) + } + + return ( + +
+ } + actions={ fetchGlobalUsers(pagination.current, pagination.pageSize)}>{t('tenantGlobalUserAuthorization.actions.refresh')}} + /> + + {error && ( +
+ {error} +
+ )} + +
+
+ setSearchTerm(e.target.value)} + icon={} + /> +
+
+ +
+
+

+ {t('tenantGlobalUserAuthorization.list.title', { count: users.length })} +

+ + {users.length === 0 ? ( + + ) : ( + <> + ( +
+ {user.avatar ? ( + {user.nickname + ) : ( +
+ +
+ )} +
+
{user.nickname || t('tenantGlobalUserAuthorization.common.noNickname')}
+
{user.email}
+
+
+ ) + }, + { + key: 'created_at', + title: t('tenantGlobalUserAuthorization.table.createdAt'), + render: (user: GlobalUserCandidate) => ( + {new Date(user.created_at).toLocaleDateString(locale)} + ) + }, + { + key: 'actions', + title: t('tenantGlobalUserAuthorization.table.actions'), + align: 'right', + render: (user: GlobalUserCandidate) => ( + handleAuthorize(user)} + disabled={submittingUserId === user.id} + leftIcon={submittingUserId === user.id ? : } + > + {submittingUserId === user.id + ? t('tenantGlobalUserAuthorization.actions.authorizing') + : t('tenantGlobalUserAuthorization.actions.authorize')} + + ) + }, + ]} + data={users} + rowKey={(row) => row.id} + /> + + {users.length > 0 && ( +
+
+
+ {t('tenantGlobalUserAuthorization.pagination.showing', { + start: ((pagination.current - 1) * pagination.pageSize) + 1, + end: Math.min(pagination.current * pagination.pageSize, pagination.total), + total: pagination.total, + })} +
+
+ + + {t('tenantGlobalUserAuthorization.pagination.pageInfo', { + current: pagination.current, + total: Math.max(1, Math.ceil(pagination.total / pagination.pageSize)), + })} + + +
+
+
+ )} + + )} +
+
+
+
+ ) +} diff --git a/basaltpass-frontend/src/shared/api/tenant/tenantUserManagement.ts b/basaltpass-frontend/src/shared/api/tenant/tenantUserManagement.ts index b3ed748f..af8519ba 100644 --- a/basaltpass-frontend/src/shared/api/tenant/tenantUserManagement.ts +++ b/basaltpass-frontend/src/shared/api/tenant/tenantUserManagement.ts @@ -46,6 +46,27 @@ export interface InviteTenantUserRequest { message?: string } +export interface GlobalUserCandidate { + id: number + email: string + nickname: string + avatar?: string + created_at: string +} + +export interface GlobalUserCandidatesResponse { + users: GlobalUserCandidate[] + pagination: { + page: number + limit: number + total: number + } +} + +export interface AuthorizeGlobalUserRequest { + role?: 'admin' | 'user' +} + // tenantusermanagementAPI export const tenantUserManagementApi = { // gettenantuserlist @@ -94,6 +115,22 @@ export const tenantUserManagementApi = { async getTenantUser(userId: number) { const response = await client.get(`/api/v1/tenant/users/${userId}`) return response.data + }, + + // 获取可加入当前租户的全局用户 + async getGlobalUserCandidates(params?: { + page?: number + limit?: number + search?: string + }): Promise { + const response = await client.get('/api/v1/tenant/users/global-candidates', { params }) + return response.data + }, + + // 授权全局用户加入当前租户 + async authorizeGlobalUser(userId: number, data?: AuthorizeGlobalUserRequest) { + const response = await client.post(`/api/v1/tenant/users/global-candidates/${userId}/authorize`, data || {}) + return response.data } } diff --git a/basaltpass-frontend/src/shared/constants/routes/tenant.ts b/basaltpass-frontend/src/shared/constants/routes/tenant.ts index 29e32c19..9117d245 100644 --- a/basaltpass-frontend/src/shared/constants/routes/tenant.ts +++ b/basaltpass-frontend/src/shared/constants/routes/tenant.ts @@ -8,6 +8,7 @@ export const TENANT_ROUTES = { crossAppTrusts: '/tenant/cross-app-trusts', automationTokens: '/tenant/automation-tokens', users: '/tenant/users', + globalUserAuthorization: '/tenant/users/global-authorization', teams: '/tenant/teams', roles: '/tenant/roles', permissions: '/tenant/permissions', diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index 5e30b792..eff6b81c 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -1481,6 +1481,7 @@ const en = { automationTokens: 'Automation Tokens', userManagement: 'User Management', userList: 'Users', + globalUserAuthorization: 'Global User Authorization', teamManagement: 'Teams', walletManagement: 'Wallet Management', giftCardManagement: 'Gift Card Management', @@ -3035,6 +3036,46 @@ const en = { fetchAppsFailed: 'Failed to fetch app list:', }, }, + tenantGlobalUserAuthorization: { + page: { + title: 'Global User Authorization', + subtitle: 'Authorize platform-level users to join current tenant', + }, + common: { + noNickname: 'No nickname', + }, + actions: { + refresh: 'Refresh', + authorize: 'Authorize Join', + authorizing: 'Authorizing...', + }, + filters: { + searchPlaceholder: 'Search email or nickname...', + }, + list: { + title: 'Global Users ({{count}} users)', + }, + table: { + user: 'User', + createdAt: 'Created At', + actions: 'Actions', + }, + empty: { + title: 'No global users available', + description: 'No platform-level user can be authorized at the moment', + }, + pagination: { + showing: 'Showing {{start}} - {{end}}, total {{total}}', + pageInfo: 'Page {{current}} of {{total}}', + }, + confirm: { + authorizeUser: 'Authorize {{email}} to join this tenant?', + }, + errors: { + fetchFailed: 'Failed to fetch global users', + authorizeFailed: 'Failed to authorize this user', + }, + }, tenantRoleManagement: { layoutTitle: 'Role & Permission Management', header: { diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index db4fea1e..d5e20c8b 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -1481,6 +1481,7 @@ const en = { automationTokens: '自动化 API 令牌', userManagement: 'User Management', userList: 'Users', + globalUserAuthorization: '全局用户授权', teamManagement: 'Teams', walletManagement: 'Wallet Management', giftCardManagement: 'Gift Card Management', @@ -3035,6 +3036,46 @@ const en = { fetchAppsFailed: 'Failed to fetch app list:', }, }, + tenantGlobalUserAuthorization: { + page: { + title: '全局用户授权', + subtitle: '将平台级用户授权加入当前租户', + }, + common: { + noNickname: '未设置昵称', + }, + actions: { + refresh: '刷新', + authorize: '授权加入', + authorizing: '授权中...', + }, + filters: { + searchPlaceholder: '搜索邮箱或昵称...', + }, + list: { + title: '全局用户({{count}}人)', + }, + table: { + user: '用户', + createdAt: '创建时间', + actions: '操作', + }, + empty: { + title: '暂无可授权的全局用户', + description: '当前没有可加入本租户的平台级用户', + }, + pagination: { + showing: '显示 {{start}} - {{end}},共 {{total}} 条', + pageInfo: '第 {{current}} / {{total}} 页', + }, + confirm: { + authorizeUser: '确认授权 {{email}} 加入当前租户吗?', + }, + errors: { + fetchFailed: '获取全局用户失败', + authorizeFailed: '授权加入失败', + }, + }, tenantRoleManagement: { layoutTitle: 'Role & Permission Management', header: { From c834b1f81fe590929a3b3dec17f3d1567203db97 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 12:35:13 -0700 Subject: [PATCH 07/21] feat(frontend): show tenant join/login/register links in admin and tenant panels --- .../features/admin/tenant/TenantDetail.tsx | 102 +++++++++++++++--- .../src/features/tenant/Dashboard.tsx | 35 +++++- .../src/features/tenant/TenantInfo.tsx | 35 ++++++ .../src/shared/i18n/messages/en.ts | 6 ++ .../src/shared/i18n/messages/zh.ts | 6 ++ 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx index abffdf46..66ae5ecc 100644 --- a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx +++ b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx @@ -5,6 +5,8 @@ import { BuildingOfficeIcon, DocumentTextIcon, CogIcon, + LinkIcon, + ClipboardDocumentIcon, ExclamationTriangleIcon, CheckCircleIcon, UserIcon, @@ -38,6 +40,7 @@ const TenantDetail: React.FC = () => { const [authLoading, setAuthLoading] = useState(false) const [authSaving, setAuthSaving] = useState(false) const [authError, setAuthError] = useState(null) + const [copiedField, setCopiedField] = useState(null) const [formData, setFormData] = useState({ name: '', description: '', @@ -223,15 +226,47 @@ const TenantDetail: React.FC = () => { return new Intl.NumberFormat(locale).format(value ?? 0) } + const copyToClipboard = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } catch { + setCopiedField(null) + } + } + + const userConsoleBaseUrl = useMemo(() => { + const configured = (import.meta as any).env?.VITE_CONSOLE_USER_URL + if (configured && typeof configured === 'string') { + return configured.replace(/\/+$/, '') + } + if (typeof window !== 'undefined') { + return window.location.origin + } + return '' + }, []) + const tenantLoginUrl = useMemo(() => { - if (!tenant?.code) { + if (!tenant?.code || !userConsoleBaseUrl) { + return '' + } + return `${userConsoleBaseUrl}/auth/tenant/${tenant.code}/login` + }, [tenant?.code, userConsoleBaseUrl]) + + const tenantRegisterUrl = useMemo(() => { + if (!tenant?.code || !userConsoleBaseUrl) { return '' } - if (typeof window === 'undefined') { - return `/auth/tenant/${tenant.code}/login` + return `${userConsoleBaseUrl}/auth/tenant/${tenant.code}/register` + }, [tenant?.code, userConsoleBaseUrl]) + + const tenantJoinUrl = useMemo(() => { + if (!tenant?.code || !userConsoleBaseUrl) { + return '' } - return `${window.location.origin}/auth/tenant/${tenant.code}/login` - }, [tenant?.code]) + return `${userConsoleBaseUrl}/tenant/${tenant.code}/register` + }, [tenant?.code, userConsoleBaseUrl]) if (loading) { return ( @@ -379,16 +414,53 @@ const TenantDetail: React.FC = () => {
{tenant.owner_email}
-
{t('adminTenantDetail.meta.loginUrl')}
-
- - {tenantLoginUrl} - +
+ + {t('adminTenantDetail.meta.accessLinks')} +
+
+
+
{t('adminTenantDetail.meta.joinUrl')}
+
+ + copyToClipboard(tenantJoinUrl, 'join')} + title={t('adminTenantDetail.actions.copyLink')} + > + {copiedField === 'join' ? : } + +
+
+
+
{t('adminTenantDetail.meta.loginUrl')}
+
+ + copyToClipboard(tenantLoginUrl, 'login')} + title={t('adminTenantDetail.actions.copyLink')} + > + {copiedField === 'login' ? : } + +
+
+
+
{t('adminTenantDetail.meta.registerUrl')}
+
+ + copyToClipboard(tenantRegisterUrl, 'register')} + title={t('adminTenantDetail.actions.copyLink')} + > + {copiedField === 'register' ? : } + +
+
diff --git a/basaltpass-frontend/src/features/tenant/Dashboard.tsx b/basaltpass-frontend/src/features/tenant/Dashboard.tsx index 5cb8f4c4..43ce4df9 100644 --- a/basaltpass-frontend/src/features/tenant/Dashboard.tsx +++ b/basaltpass-frontend/src/features/tenant/Dashboard.tsx @@ -184,6 +184,11 @@ export default function TenantDashboard() { return `${baseUrl}/auth/tenant/${tenantCode}/register` } + const getJoinUrl = () => { + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + return `${baseUrl}/tenant/${tenantCode}/register` + } + const handleLivenessCheck = async () => { try { setIsCheckingLiveness(true) @@ -298,7 +303,7 @@ export default function TenantDashboard() {

{t('tenantDashboardPage.userAccessLinks.description')}

-
+
{/* */}
+ {/* 加入链接 */} +
+ +
+
+ +
+ copyToClipboard(getJoinUrl(), 'join')} + title={t('tenantDashboardPage.actions.copyLink')} + > + {copiedField === 'join' ? ( + + ) : ( + + )} + +
+
{/* */}
diff --git a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx index 333efaf1..bb8a1b98 100644 --- a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx +++ b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx @@ -301,6 +301,11 @@ export default function TenantInfoPage() { return `${baseUrl}/auth/tenant/${tenantInfo?.code}/register` } + const getJoinUrl = () => { + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + return `${baseUrl}/tenant/${tenantInfo?.code}/register` + } + const loginEnabled = authSettings?.allow_login ?? true const registrationEnabled = authSettings?.allow_registration ?? true @@ -570,6 +575,36 @@ export default function TenantInfoPage() {
+ {/* 加入链接 */} +
+
+ + {registrationEnabled ? '可访问' : '已禁用'} +
+
+ + copyToClipboard(getJoinUrl(), 'join')} + title={t('tenantInfoPage.actions.copyLink')} + > + {copiedField === 'join' ? ( + + ) : ( + + )} + +
+
+ {/* */}
diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index eff6b81c..f1a5a92e 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -3895,6 +3895,7 @@ const en = { userAccessLinks: { title: 'User Access Links', description: 'Share these links with your users for sign in or sign up', + joinPage: 'Join Page', loginPage: 'Login Page', registerPage: 'Register Page', tip: 'Share these links with your users so they can directly access your tenant login and registration pages.', @@ -3968,6 +3969,7 @@ const en = { userAccessLinks: { title: 'User Access Links', description: 'Share these links with your users for sign in or sign up', + joinPage: 'Join Page', loginPage: 'Login Page', registerPage: 'Register Page', tip: 'Share these links with your users so they can directly access your tenant login and registration pages.', @@ -4558,13 +4560,17 @@ const en = { tenantCode: 'Tenant Code', description: 'Description', ownerEmail: 'Owner Email', + accessLinks: 'Tenant Access Links', + joinUrl: 'Join URL', loginUrl: 'Tenant Console URL', + registerUrl: 'Registration URL', createdAt: 'Created At', updatedAt: 'Updated At', createdAtLabel: 'Created At', }, actions: { backToList: 'Back to Tenant List', + copyLink: 'Copy Link', cancelEdit: 'Cancel Edit', edit: 'Edit', delete: 'Delete', diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index d5e20c8b..c5da83ac 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -3895,6 +3895,7 @@ const en = { userAccessLinks: { title: 'User Access Links', description: 'Share these links with your users for sign in or sign up', + joinPage: 'Join Page', loginPage: 'Login Page', registerPage: 'Register Page', tip: 'Share these links with your users so they can directly access your tenant login and registration pages.', @@ -3968,6 +3969,7 @@ const en = { userAccessLinks: { title: 'User Access Links', description: 'Share these links with your users for sign in or sign up', + joinPage: 'Join Page', loginPage: 'Login Page', registerPage: 'Register Page', tip: 'Share these links with your users so they can directly access your tenant login and registration pages.', @@ -4558,13 +4560,17 @@ const en = { tenantCode: 'Tenant Code', description: 'Description', ownerEmail: 'Owner Email', + accessLinks: 'Tenant Access Links', + joinUrl: 'Join URL', loginUrl: 'Tenant Console URL', + registerUrl: 'Registration URL', createdAt: 'Created At', updatedAt: 'Updated At', createdAtLabel: 'Created At', }, actions: { backToList: 'Back to Tenant List', + copyLink: 'Copy Link', cancelEdit: 'Cancel Edit', edit: 'Edit', delete: 'Delete', From 0c36dc8082278e132763b1f7fd5cfbeef50e320f Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 12:43:53 -0700 Subject: [PATCH 08/21] fix(frontend): correct tenant join link route and base url fallback --- .../src/features/admin/tenant/TenantDetail.tsx | 2 +- basaltpass-frontend/src/features/tenant/Dashboard.tsx | 8 ++++---- basaltpass-frontend/src/features/tenant/TenantInfo.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx index 66ae5ecc..b3efd11d 100644 --- a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx +++ b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx @@ -265,7 +265,7 @@ const TenantDetail: React.FC = () => { if (!tenant?.code || !userConsoleBaseUrl) { return '' } - return `${userConsoleBaseUrl}/tenant/${tenant.code}/register` + return `${userConsoleBaseUrl}/auth/tenant/${tenant.code}/register` }, [tenant?.code, userConsoleBaseUrl]) if (loading) { diff --git a/basaltpass-frontend/src/features/tenant/Dashboard.tsx b/basaltpass-frontend/src/features/tenant/Dashboard.tsx index 43ce4df9..b0257410 100644 --- a/basaltpass-frontend/src/features/tenant/Dashboard.tsx +++ b/basaltpass-frontend/src/features/tenant/Dashboard.tsx @@ -175,18 +175,18 @@ export default function TenantDashboard() { } const getLoginUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin return `${baseUrl}/auth/tenant/${tenantCode}/login` } const getRegisterUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin return `${baseUrl}/auth/tenant/${tenantCode}/register` } const getJoinUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' - return `${baseUrl}/tenant/${tenantCode}/register` + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin + return `${baseUrl}/auth/tenant/${tenantCode}/register` } const handleLivenessCheck = async () => { diff --git a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx index bb8a1b98..1b5c356b 100644 --- a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx +++ b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx @@ -292,18 +292,18 @@ export default function TenantInfoPage() { } const getLoginUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin return `${baseUrl}/auth/tenant/${tenantInfo?.code}/login` } const getRegisterUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin return `${baseUrl}/auth/tenant/${tenantInfo?.code}/register` } const getJoinUrl = () => { - const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || 'http://localhost:5101' - return `${baseUrl}/tenant/${tenantInfo?.code}/register` + const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin + return `${baseUrl}/auth/tenant/${tenantInfo?.code}/register` } const loginEnabled = authSettings?.allow_login ?? true From 0cef72ffa2c55810cae97c558b9019f04152482b Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 12:54:28 -0700 Subject: [PATCH 09/21] feat(join): add tenant join route and authenticated join flow --- .../internal/api/v1/routes/user.go | 1 + .../internal/handler/user/handler.go | 98 ++++++++++ basaltpass-frontend/apps/user/src/router.tsx | 17 ++ .../features/admin/tenant/TenantDetail.tsx | 2 +- .../src/features/auth/TenantJoin.tsx | 171 ++++++++++++++++++ .../src/features/tenant/Dashboard.tsx | 2 +- .../src/features/tenant/TenantInfo.tsx | 2 +- basaltpass-frontend/src/routes/authRoutes.tsx | 2 + .../src/shared/i18n/messages/en.ts | 28 +++ .../src/shared/i18n/messages/zh.ts | 28 +++ .../src/shared/routes/PublicRoute.tsx | 4 +- 11 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 basaltpass-frontend/src/features/auth/TenantJoin.tsx diff --git a/basaltpass-backend/internal/api/v1/routes/user.go b/basaltpass-backend/internal/api/v1/routes/user.go index 3b5c68ff..7dd8c9df 100644 --- a/basaltpass-backend/internal/api/v1/routes/user.go +++ b/basaltpass-backend/internal/api/v1/routes/user.go @@ -27,6 +27,7 @@ func RegisterUserRoutes(v1 fiber.Router) { // 用户租户管理 userGroup := v1.Group("/user", middleware.JWTMiddleware()) userGroup.Get("/tenants", user.GetUserTenantsHandler) + userGroup.Post("/tenants/join-by-code/:code", user.JoinTenantByCodeHandler) userGroup.Get("/profile", user.GetProfileHandler) userGroup.Put("/profile", user.UpdateProfileHandler) diff --git a/basaltpass-backend/internal/handler/user/handler.go b/basaltpass-backend/internal/handler/user/handler.go index 8d133f47..3dd325b8 100644 --- a/basaltpass-backend/internal/handler/user/handler.go +++ b/basaltpass-backend/internal/handler/user/handler.go @@ -5,9 +5,12 @@ import ( userdto "basaltpass-backend/internal/dto/user" "basaltpass-backend/internal/model" tenant2 "basaltpass-backend/internal/service/tenant" + "errors" "strconv" + "strings" "github.com/gofiber/fiber/v2" + "gorm.io/gorm" ) var svc = Service{} @@ -44,6 +47,101 @@ func GetUserTenantsHandler(c *fiber.Ctx) error { }) } +// JoinTenantByCodeHandler allows a logged-in user to join a tenant via tenant code. +// POST /user/tenants/join-by-code/:code +func JoinTenantByCodeHandler(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + tenantCode := strings.TrimSpace(c.Params("code")) + if tenantCode == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "tenant code is required", + }) + } + + db := common.DB() + + var user model.User + if err := db.Select("id", "tenant_id", "is_system_admin").First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to load user"}) + } + + if user.IsSuperAdmin() { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "system admin cannot join tenant via join link", + }) + } + + var tenant model.Tenant + if err := db.Where("code = ? AND status = ?", tenantCode, model.TenantStatusActive). + Select("id", "name", "code", "status"). + First(&tenant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to load tenant"}) + } + + if user.TenantID != 0 && user.TenantID != tenant.ID { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "error": "user already belongs to another tenant", + }) + } + + tx := db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + joinedNow := false + var membership model.TenantUser + err := tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).First(&membership).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to join tenant"}) + } + + membership = model.TenantUser{ + TenantID: tenant.ID, + UserID: user.ID, + Role: model.TenantRoleMember, + } + if err := tx.Create(&membership).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to join tenant"}) + } + joinedNow = true + } + + if user.TenantID == 0 { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", tenant.ID).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to join tenant"}) + } + joinedNow = true + } + + if err := tx.Commit().Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to join tenant"}) + } + + return c.JSON(fiber.Map{ + "message": "joined tenant successfully", + "joined": joinedNow, + "already_joined": !joinedNow, + "tenant": fiber.Map{ + "id": tenant.ID, + "name": tenant.Name, + "code": tenant.Code, + }, + }) +} + // UpdateProfileHandler handles PUT /user/profile func UpdateProfileHandler(c *fiber.Ctx) error { uid := c.Locals("userID").(uint) diff --git a/basaltpass-frontend/apps/user/src/router.tsx b/basaltpass-frontend/apps/user/src/router.tsx index 41ea5220..d866d10d 100644 --- a/basaltpass-frontend/apps/user/src/router.tsx +++ b/basaltpass-frontend/apps/user/src/router.tsx @@ -5,6 +5,7 @@ import Login from '../../../src/features/auth/Login' import Register from '../../../src/features/auth/Register' import TenantLogin from '../../../src/features/auth/TenantLogin' import TenantRegister from '../../../src/features/auth/TenantRegister' +import TenantJoin from '../../../src/features/auth/TenantJoin' import OauthSuccess from '../../../src/features/auth/OauthSuccess' import OAuthConsent from '../../../src/features/auth/OAuthConsent' import ResetPassword from '../../../src/features/auth/ResetPassword' @@ -90,6 +91,14 @@ export default function AppRouter() { } /> + + + + } + /> {/* Backward compatibility */} } /> + + + + } + /> } /> } /> } /> diff --git a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx index b3efd11d..df62761d 100644 --- a/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx +++ b/basaltpass-frontend/src/features/admin/tenant/TenantDetail.tsx @@ -265,7 +265,7 @@ const TenantDetail: React.FC = () => { if (!tenant?.code || !userConsoleBaseUrl) { return '' } - return `${userConsoleBaseUrl}/auth/tenant/${tenant.code}/register` + return `${userConsoleBaseUrl}/auth/tenant/${tenant.code}/join` }, [tenant?.code, userConsoleBaseUrl]) if (loading) { diff --git a/basaltpass-frontend/src/features/auth/TenantJoin.tsx b/basaltpass-frontend/src/features/auth/TenantJoin.tsx new file mode 100644 index 00000000..3f152220 --- /dev/null +++ b/basaltpass-frontend/src/features/auth/TenantJoin.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import client from '@api/client' +import PSkeleton from '@ui/PSkeleton' +import { PAlert, PButton } from '@ui' +import { ROUTES } from '@constants' +import { useAuth } from '@contexts/AuthContext' +import { useConfig } from '@contexts/ConfigContext' +import { useI18n } from '@shared/i18n' +import { TenantLoginShell } from './tenant-login/TenantLoginShell' +import { useTenantInfo } from './tenant-login/useTenantInfo' + +function TenantJoin() { + const { tenantCode } = useParams<{ tenantCode: string }>() + const { isAuthenticated, isLoading: isAuthLoading, checkAuth } = useAuth() + const { setPageTitle } = useConfig() + const { t } = useI18n() + + const [tenantLoadError, setTenantLoadError] = useState('') + const [joinError, setJoinError] = useState('') + const [joining, setJoining] = useState(false) + const [joinSuccess, setJoinSuccess] = useState(false) + const [alreadyJoined, setAlreadyJoined] = useState(false) + const [attemptedAutoJoin, setAttemptedAutoJoin] = useState(false) + + const { tenantInfo, loadingTenant } = useTenantInfo({ + tenantCode, + setPageTitle, + onError: setTenantLoadError, + }) + + const loginRedirectHref = (() => { + if (typeof window === 'undefined') { + return ROUTES.user.login + } + const fullPath = `${window.location.pathname}${window.location.search}` + return `${ROUTES.user.login}?redirect=${encodeURIComponent(fullPath)}` + })() + + const joinTenant = useCallback(async () => { + if (!tenantCode || joining) { + return + } + + setJoining(true) + setJoinError('') + + try { + const res = await client.post(`/api/v1/user/tenants/join-by-code/${encodeURIComponent(tenantCode)}`) + setAlreadyJoined(Boolean(res?.data?.already_joined)) + setJoinSuccess(true) + await checkAuth() + } catch (err: any) { + setJoinSuccess(false) + setJoinError(err?.response?.data?.error || t('auth.tenantJoin.errors.joinFailed')) + } finally { + setJoining(false) + } + }, [checkAuth, joining, t, tenantCode]) + + useEffect(() => { + if (!isAuthenticated || isAuthLoading || !tenantInfo || attemptedAutoJoin) { + return + } + + setAttemptedAutoJoin(true) + void joinTenant() + }, [attemptedAutoJoin, isAuthLoading, isAuthenticated, joinTenant, tenantInfo]) + + if (loadingTenant || isAuthLoading) { + return + } + + if (!tenantInfo && !loadingTenant) { + return ( + {tenantLoadError || t('auth.tenant.notFoundDescription')}} + tenantCode={tenantCode} + tenantInfo={tenantInfo} + > +
+ + {t('auth.tenant.backToPlatformLogin')} + +
+
+ ) + } + + if (!isAuthenticated) { + return ( + + {t('auth.tenantJoin.loginRequired.descriptionPrefix')} {tenantInfo?.name} {t('auth.tenantJoin.loginRequired.descriptionSuffix')} + + } + tenantCode={tenantCode} + tenantInfo={tenantInfo} + > +
+ + + {t('auth.tenantJoin.actions.loginToJoin')} + + +
+ + {t('auth.tenantJoin.actions.goTenantLogin')} + +
+
+
+ ) + } + + return ( + + {t('auth.tenantJoin.success.alreadyJoinedPrefix')} {tenantInfo?.name} {t('auth.tenantJoin.success.alreadyJoinedSuffix')} + + ) : ( + <> + {t('auth.tenantJoin.success.joinedPrefix')} {tenantInfo?.name} {t('auth.tenantJoin.success.joinedSuffix')} + + ) + ) : ( + <> + {t('auth.tenantJoin.descriptionPrefix')} {tenantInfo?.name} {t('auth.tenantJoin.descriptionSuffix')} + + ) + } + tenantCode={tenantCode} + tenantInfo={tenantInfo} + > +
+ {joinError ? : null} + + {!joinSuccess ? ( + void joinTenant()} loading={joining} fullWidth> + {joining ? t('auth.tenantJoin.actions.joining') : t('auth.tenantJoin.actions.joinNow')} + + ) : null} + +
+ + + {t('auth.tenantJoin.actions.goTenantLogin')} + + + + + {t('auth.tenantJoin.actions.goUserDashboard')} + + +
+
+
+ ) +} + +export default TenantJoin diff --git a/basaltpass-frontend/src/features/tenant/Dashboard.tsx b/basaltpass-frontend/src/features/tenant/Dashboard.tsx index b0257410..5e13d5f6 100644 --- a/basaltpass-frontend/src/features/tenant/Dashboard.tsx +++ b/basaltpass-frontend/src/features/tenant/Dashboard.tsx @@ -186,7 +186,7 @@ export default function TenantDashboard() { const getJoinUrl = () => { const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin - return `${baseUrl}/auth/tenant/${tenantCode}/register` + return `${baseUrl}/auth/tenant/${tenantCode}/join` } const handleLivenessCheck = async () => { diff --git a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx index 1b5c356b..0850c273 100644 --- a/basaltpass-frontend/src/features/tenant/TenantInfo.tsx +++ b/basaltpass-frontend/src/features/tenant/TenantInfo.tsx @@ -303,7 +303,7 @@ export default function TenantInfoPage() { const getJoinUrl = () => { const baseUrl = (import.meta as any).env?.VITE_CONSOLE_USER_URL || window.location.origin - return `${baseUrl}/auth/tenant/${tenantInfo?.code}/register` + return `${baseUrl}/auth/tenant/${tenantInfo?.code}/join` } const loginEnabled = authSettings?.allow_login ?? true diff --git a/basaltpass-frontend/src/routes/authRoutes.tsx b/basaltpass-frontend/src/routes/authRoutes.tsx index e451b094..283c1747 100644 --- a/basaltpass-frontend/src/routes/authRoutes.tsx +++ b/basaltpass-frontend/src/routes/authRoutes.tsx @@ -3,6 +3,7 @@ import PublicRoute from '@routes/PublicRoute' import Login from '@pages/auth/Login' import TenantLogin from '@features/auth/TenantLogin' import TenantRegister from '@features/auth/TenantRegister' +import TenantJoin from '@features/auth/TenantJoin' import Register from '@pages/auth/Register' import OauthSuccess from '@pages/auth/OauthSuccess' import OAuthConsent from '@pages/auth/OAuthConsent' @@ -25,6 +26,7 @@ export function AuthRoutes() { /> } /> } /> + } /> Date: Sat, 18 Apr 2026 18:45:36 -0700 Subject: [PATCH 10/21] Refine account switching UX and unblock global signup --- .../internal/api/v1/routes/oauth.go | 1 + .../internal/handler/public/auth/handler.go | 71 +++ .../internal/handler/tenant/user_handler.go | 15 +- .../internal/handler/user/handler.go | 18 +- .../internal/handler/user/service.go | 9 - .../internal/service/auth/service.go | 52 +- .../internal/service/verification/service.go | 13 +- .../apps/admin/dist/index.html | 4 +- .../apps/tenant/dist/index.html | 4 +- basaltpass-frontend/apps/user/dist/index.html | 4 +- .../features/admin/components/AdminLayout.tsx | 6 +- .../src/features/auth/TenantJoin.tsx | 19 +- .../tenant/components/TenantLayout.tsx | 6 +- .../src/features/user/components/Layout.tsx | 22 +- .../ConsoleAccountSwitcherModal.tsx | 457 ++++++++++++++++-- .../src/shared/contexts/AuthContext.tsx | 42 +- .../src/shared/generated/buildInfo.ts | 4 +- .../src/shared/i18n/messages/en.ts | 16 + .../src/shared/i18n/messages/zh.ts | 16 + .../src/shared/utils/userSessions.ts | 17 + 20 files changed, 699 insertions(+), 97 deletions(-) diff --git a/basaltpass-backend/internal/api/v1/routes/oauth.go b/basaltpass-backend/internal/api/v1/routes/oauth.go index a71969d6..51f3e4db 100644 --- a/basaltpass-backend/internal/api/v1/routes/oauth.go +++ b/basaltpass-backend/internal/api/v1/routes/oauth.go @@ -54,6 +54,7 @@ func RegisterOAuthRoutes(v1 fiber.Router) { ratelimit.Verify2FARateLimit(), timeout.NewWithContext(auth2.Verify2FAHandler, authRouteTimeout), ) + authGroup.Post("/identity/switch", middleware.JWTMiddleware(), auth2.SwitchUserTenantIdentityHandler) authGroup.Post("/console/authorize", middleware.JWTMiddleware(), auth2.ConsoleAuthorizeHandler) authGroup.Post("/console/exchange", auth2.ConsoleExchangeHandler) diff --git a/basaltpass-backend/internal/handler/public/auth/handler.go b/basaltpass-backend/internal/handler/public/auth/handler.go index 0d0800eb..85c01783 100644 --- a/basaltpass-backend/internal/handler/public/auth/handler.go +++ b/basaltpass-backend/internal/handler/public/auth/handler.go @@ -1,8 +1,10 @@ package auth import ( + "basaltpass-backend/internal/common" "basaltpass-backend/internal/config" security "basaltpass-backend/internal/handler/user/security" + "basaltpass-backend/internal/model" auth2 "basaltpass-backend/internal/service/auth" "errors" "log" @@ -86,6 +88,75 @@ func setAuthCookies(c *fiber.Ctx, scope, accessToken, refreshToken string) { } } +type switchUserTenantIdentityRequest struct { + TenantID *uint `json:"tenant_id"` +} + +// SwitchUserTenantIdentityHandler switches the active tenant identity inside user console. +// POST /api/v1/auth/identity/switch (JWT required, user scope) +func SwitchUserTenantIdentityHandler(c *fiber.Ctx) error { + scope := normalizeScope(strings.TrimSpace(c.Get("X-Auth-Scope"))) + if scope == "" { + if claimScope, ok := c.Locals("scope").(string); ok { + scope = normalizeScope(claimScope) + } + } + if scope != auth2.ConsoleScopeUser { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "user scope required"}) + } + + uidAny := c.Locals("userID") + uid, ok := uidAny.(uint) + if !ok || uid == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + var req switchUserTenantIdentityRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + if req.TenantID == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id is required"}) + } + targetTenantID := *req.TenantID + + var user model.User + if err := common.DB().Select("id", "tenant_id").First(&user, uid).Error; err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"}) + } + + if targetTenantID == 0 { + if user.TenantID != 0 { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "tenant account cannot switch to global identity"}) + } + } else if user.TenantID != targetTenantID { + var membershipCount int64 + if err := common.DB().Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ? AND role IN ?", uid, targetTenantID, []model.TenantRole{model.TenantRoleOwner, model.TenantRoleAdmin, model.TenantRoleMember, model.TenantRoleUser}). + Count(&membershipCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to validate tenant identity"}) + } + if membershipCount == 0 { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "tenant identity not available"}) + } + } + + tokens, err := auth2.GenerateTokenPairWithTenantAndScope(uid, targetTenantID, auth2.ConsoleScopeUser) + if err != nil { + if errors.Is(err, auth2.ErrTenantLoginDisabled) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to switch identity"}) + } + + setAuthCookies(c, auth2.ConsoleScopeUser, tokens.AccessToken, tokens.RefreshToken) + return c.JSON(fiber.Map{ + "access_token": tokens.AccessToken, + "tenant_id": targetTenantID, + "scope": auth2.ConsoleScopeUser, + }) +} + // LoginHandler handles POST /auth/login func LoginHandler(c *fiber.Ctx) error { var req auth2.LoginRequest diff --git a/basaltpass-backend/internal/handler/tenant/user_handler.go b/basaltpass-backend/internal/handler/tenant/user_handler.go index 91828beb..3b8002de 100644 --- a/basaltpass-backend/internal/handler/tenant/user_handler.go +++ b/basaltpass-backend/internal/handler/tenant/user_handler.go @@ -491,7 +491,14 @@ func AuthorizeGlobalUserToTenantHandler(c *fiber.Ctx) error { }) } - if user.TenantID != 0 && user.TenantID != tenantID { + var membershipCount int64 + if err := common.DB().Model(&model.TenantUser{}).Where("user_id = ?", user.ID).Count(&membershipCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "查询用户失败", + }) + } + + if user.TenantID != 0 && user.TenantID != tenantID && membershipCount == 0 { return c.Status(fiber.StatusConflict).JSON(fiber.Map{ "error": "用户已属于其他租户", }) @@ -504,8 +511,10 @@ func AuthorizeGlobalUserToTenantHandler(c *fiber.Ctx) error { } }() - if user.TenantID == 0 { - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", tenantID).Error; err != nil { + // Keep global user identity in users.tenant_id=0 and rely on tenant_users for tenant perspective. + // For legacy drifted data, normalize back to 0 when membership exists. + if user.TenantID != 0 && membershipCount > 0 { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", 0).Error; err != nil { tx.Rollback() return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "授权加入租户失败", diff --git a/basaltpass-backend/internal/handler/user/handler.go b/basaltpass-backend/internal/handler/user/handler.go index 3dd325b8..394dba6b 100644 --- a/basaltpass-backend/internal/handler/user/handler.go +++ b/basaltpass-backend/internal/handler/user/handler.go @@ -74,6 +74,11 @@ func JoinTenantByCodeHandler(c *fiber.Ctx) error { }) } + var membershipCount int64 + if err := db.Model(&model.TenantUser{}).Where("user_id = ?", user.ID).Count(&membershipCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to load user membership"}) + } + var tenant model.Tenant if err := db.Where("code = ? AND status = ?", tenantCode, model.TenantStatusActive). Select("id", "name", "code", "status"). @@ -84,7 +89,7 @@ func JoinTenantByCodeHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to load tenant"}) } - if user.TenantID != 0 && user.TenantID != tenant.ID { + if user.TenantID != 0 && user.TenantID != tenant.ID && membershipCount == 0 { return c.Status(fiber.StatusConflict).JSON(fiber.Map{ "error": "user already belongs to another tenant", }) @@ -118,12 +123,13 @@ func JoinTenantByCodeHandler(c *fiber.Ctx) error { joinedNow = true } - if user.TenantID == 0 { - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", tenant.ID).Error; err != nil { + // Keep global account identity at tenant_id=0 and use tenant_users for tenant perspective switching. + // For legacy data drift (tenant_id changed after join), normalize it back to 0 when membership exists. + if user.TenantID != 0 && membershipCount > 0 { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("tenant_id", 0).Error; err != nil { tx.Rollback() return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to join tenant"}) } - joinedNow = true } if err := tx.Commit().Error; err != nil { @@ -131,8 +137,8 @@ func JoinTenantByCodeHandler(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "message": "joined tenant successfully", - "joined": joinedNow, + "message": "joined tenant successfully", + "joined": joinedNow, "already_joined": !joinedNow, "tenant": fiber.Map{ "id": tenant.ID, diff --git a/basaltpass-backend/internal/handler/user/service.go b/basaltpass-backend/internal/handler/user/service.go index a06b20c2..e9c3c94e 100644 --- a/basaltpass-backend/internal/handler/user/service.go +++ b/basaltpass-backend/internal/handler/user/service.go @@ -31,15 +31,6 @@ func (s Service) GetProfile(userID uint, activeTenantID uint) (userdto.ProfileRe if resolvedTenantID == 0 { if u.TenantID > 0 { resolvedTenantID = u.TenantID - } else { - var firstMembership model.TenantUser - if err := common.DB(). - Select("tenant_id"). - Where("user_id = ?", userID). - Order("created_at ASC"). - First(&firstMembership).Error; err == nil { - resolvedTenantID = firstMembership.TenantID - } } } diff --git a/basaltpass-backend/internal/service/auth/service.go b/basaltpass-backend/internal/service/auth/service.go index bdd560ed..2ce31184 100644 --- a/basaltpass-backend/internal/service/auth/service.go +++ b/basaltpass-backend/internal/service/auth/service.go @@ -217,23 +217,61 @@ func (s Service) LoginV2(req LoginRequest) (LoginResult, error) { query = query.Where("tenant_id = 0") if err := query.First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - var tenantAccountCount int64 - if countErr := db.Model(&model.User{}). - Where("(email = ? OR phone = ?) AND tenant_id > 0", identifier, identifier). - Count(&tenantAccountCount).Error; countErr == nil && tenantAccountCount > 0 { - return LoginResult{}, ErrTenantAccountOnly + // 兼容历史数据:若全局账号曾被错误迁移到 tenant_id>0,但具备 tenant_users 关系, + // 仍允许从全局入口登录(token tid=0)。 + var legacyUser model.User + if legacyErr := db.Where("email = ? OR phone = ?", identifier, identifier). + Order("id ASC"). + First(&legacyUser).Error; legacyErr == nil { + var membershipCount int64 + if countErr := db.Model(&model.TenantUser{}). + Where("user_id = ?", legacyUser.ID). + Count(&membershipCount).Error; countErr == nil && membershipCount > 0 { + if preloadErr := db.Preload("Passkeys").First(&user, legacyUser.ID).Error; preloadErr == nil { + goto LOGIN_USER_FOUND + } + } + + if legacyUser.TenantID > 0 { + return LoginResult{}, ErrTenantAccountOnly + } } } return LoginResult{}, normalizeLoginQueryError(err) } } else { - // 租户登录:查询指定租户下的用户 + // 租户登录:优先查询指定租户下的本地账户 query = query.Where("tenant_id = ?", req.TenantID) if err := query.First(&user).Error; err != nil { - return LoginResult{}, normalizeLoginQueryError(err) + if !errors.Is(err, gorm.ErrRecordNotFound) { + return LoginResult{}, normalizeLoginQueryError(err) + } + + // 允许全局账号(tenant_id=0)通过 tenant_users 成员关系登录租户入口。 + var globalUser model.User + if gErr := db.Where("(email = ? OR phone = ?) AND tenant_id = 0", identifier, identifier). + First(&globalUser).Error; gErr != nil { + return LoginResult{}, normalizeLoginQueryError(err) + } + + var membershipCount int64 + if mErr := db.Model(&model.TenantUser{}). + Where("user_id = ? AND tenant_id = ?", globalUser.ID, req.TenantID). + Count(&membershipCount).Error; mErr != nil { + return LoginResult{}, fmt.Errorf("%w: %v", ErrServiceUnavailable, mErr) + } + if membershipCount == 0 { + return LoginResult{}, ErrInvalidCredentials + } + + if pErr := db.Preload("Passkeys").First(&user, globalUser.ID).Error; pErr != nil { + return LoginResult{}, normalizeLoginQueryError(pErr) + } } } +LOGIN_USER_FOUND: + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { return LoginResult{}, ErrInvalidCredentials } diff --git a/basaltpass-backend/internal/service/verification/service.go b/basaltpass-backend/internal/service/verification/service.go index 0ec6e69a..2b432407 100644 --- a/basaltpass-backend/internal/service/verification/service.go +++ b/basaltpass-backend/internal/service/verification/service.go @@ -417,11 +417,6 @@ func (s *Service) CompleteSignup(req CompleteSignupRequest) (*model.User, error) return nil, err } - if err := wallet.EnsureUserCreditWalletTx(tx, user.ID); err != nil { - tx.Rollback() - return nil, err - } - // 自动处理租户邀请(如果用户存在未处理的邀请记录) var invitations []model.TenantInvitation if err := tx.Where("email = ? AND status = ?", user.Email, "pending").Find(&invitations).Error; err == nil && len(invitations) > 0 { @@ -450,6 +445,14 @@ func (s *Service) CompleteSignup(req CompleteSignupRequest) (*model.User, error) } } + // 钱包初始化放在邀请处理之后: + // 1) 若用户因邀请获得了租户身份,可正常创建租户上下文钱包; + // 2) 对纯平台用户(无租户身份)跳过钱包初始化,不阻塞注册。 + if err := wallet.EnsureUserCreditWalletTx(tx, user.ID); err != nil && !errors.Is(err, wallet.ErrNoTenantIdentity) { + tx.Rollback() + return nil, err + } + // 清理:标记注册会话为已完成,使相关挑战失效 tx.Model(&pendingSignup).Update("status", model.SignupStatusCompleted) tx.Model(&model.VerificationChallenge{}).Where("signup_id = ? AND status = ?", diff --git a/basaltpass-frontend/apps/admin/dist/index.html b/basaltpass-frontend/apps/admin/dist/index.html index 71a4fb29..62350e6f 100644 --- a/basaltpass-frontend/apps/admin/dist/index.html +++ b/basaltpass-frontend/apps/admin/dist/index.html @@ -25,8 +25,8 @@ .catch(function () {}) })() - - + +
diff --git a/basaltpass-frontend/apps/tenant/dist/index.html b/basaltpass-frontend/apps/tenant/dist/index.html index 377e35a0..73601178 100644 --- a/basaltpass-frontend/apps/tenant/dist/index.html +++ b/basaltpass-frontend/apps/tenant/dist/index.html @@ -25,8 +25,8 @@ .catch(function () {}) })() - - + +
diff --git a/basaltpass-frontend/apps/user/dist/index.html b/basaltpass-frontend/apps/user/dist/index.html index c12ec9d2..1c4a8a0b 100644 --- a/basaltpass-frontend/apps/user/dist/index.html +++ b/basaltpass-frontend/apps/user/dist/index.html @@ -25,8 +25,8 @@ .catch(function () {}) })() - - + +
diff --git a/basaltpass-frontend/src/features/admin/components/AdminLayout.tsx b/basaltpass-frontend/src/features/admin/components/AdminLayout.tsx index 8eb781fe..a592cacc 100644 --- a/basaltpass-frontend/src/features/admin/components/AdminLayout.tsx +++ b/basaltpass-frontend/src/features/admin/components/AdminLayout.tsx @@ -181,7 +181,7 @@ export default function AdminLayout({ children, title, actions }: AdminLayoutPro {isUserMenuOpen && ( -
+

{user?.nickname || t('common.user')} @@ -215,7 +215,7 @@ export default function AdminLayout({ children, title, actions }: AdminLayoutPro setShowAccountSwitcher(true) setIsUserMenuOpen(false) }} - className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 justify-start" + className="flex w-full items-center justify-start rounded-none px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700" > {t('common.switchAccount')} @@ -226,7 +226,7 @@ export default function AdminLayout({ children, title, actions }: AdminLayoutPro {t('common.logout')} diff --git a/basaltpass-frontend/src/features/auth/TenantJoin.tsx b/basaltpass-frontend/src/features/auth/TenantJoin.tsx index 3f152220..684bc348 100644 --- a/basaltpass-frontend/src/features/auth/TenantJoin.tsx +++ b/basaltpass-frontend/src/features/auth/TenantJoin.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import { Link, useParams } from 'react-router-dom' import client from '@api/client' import PSkeleton from '@ui/PSkeleton' @@ -21,7 +21,6 @@ function TenantJoin() { const [joining, setJoining] = useState(false) const [joinSuccess, setJoinSuccess] = useState(false) const [alreadyJoined, setAlreadyJoined] = useState(false) - const [attemptedAutoJoin, setAttemptedAutoJoin] = useState(false) const { tenantInfo, loadingTenant } = useTenantInfo({ tenantCode, @@ -58,14 +57,16 @@ function TenantJoin() { } }, [checkAuth, joining, t, tenantCode]) - useEffect(() => { - if (!isAuthenticated || isAuthLoading || !tenantInfo || attemptedAutoJoin) { + const handleConfirmJoin = () => { + if (!tenantInfo) { + return + } + const ok = window.confirm(t('auth.tenantJoin.confirm.prompt', { tenantName: tenantInfo.name })) + if (!ok) { return } - - setAttemptedAutoJoin(true) void joinTenant() - }, [attemptedAutoJoin, isAuthLoading, isAuthenticated, joinTenant, tenantInfo]) + } if (loadingTenant || isAuthLoading) { return @@ -146,8 +147,8 @@ function TenantJoin() { {joinError ? : null} {!joinSuccess ? ( - void joinTenant()} loading={joining} fullWidth> - {joining ? t('auth.tenantJoin.actions.joining') : t('auth.tenantJoin.actions.joinNow')} + + {joining ? t('auth.tenantJoin.actions.joining') : t('auth.tenantJoin.actions.confirmJoin')} ) : null} diff --git a/basaltpass-frontend/src/features/tenant/components/TenantLayout.tsx b/basaltpass-frontend/src/features/tenant/components/TenantLayout.tsx index 5ffac49a..40fd3c3d 100644 --- a/basaltpass-frontend/src/features/tenant/components/TenantLayout.tsx +++ b/basaltpass-frontend/src/features/tenant/components/TenantLayout.tsx @@ -187,7 +187,7 @@ export default function TenantLayout({ children, title, actions }: TenantLayoutP {/* */} {isUserMenuOpen && ( -

+

{user?.nickname || t('common.tenantUser')} @@ -221,7 +221,7 @@ export default function TenantLayout({ children, title, actions }: TenantLayoutP setShowAccountSwitcher(true) setIsUserMenuOpen(false) }} - className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 justify-start" + className="flex w-full items-center justify-start rounded-none px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700" > {t('common.switchAccount')} @@ -232,7 +232,7 @@ export default function TenantLayout({ children, title, actions }: TenantLayoutP {t('common.logout')} diff --git a/basaltpass-frontend/src/features/user/components/Layout.tsx b/basaltpass-frontend/src/features/user/components/Layout.tsx index c148ad3f..b7b85756 100644 --- a/basaltpass-frontend/src/features/user/components/Layout.tsx +++ b/basaltpass-frontend/src/features/user/components/Layout.tsx @@ -43,7 +43,7 @@ export default function Layout({ children }: LayoutProps) { const mobileUserMenuRef = useRef(null) const location = useLocation() const { t } = useI18n() - const { user, tenants, logout, canAccessTenant, canAccessAdmin, canUseWallet } = useAuth() + const { user, tenants, logout, switchAccount, switchTenantIdentity, canAccessTenant, canAccessAdmin, canUseWallet } = useAuth() const { marketEnabled, siteName, siteInitial, setPageTitle } = useConfig() const currentSessionKey = `${user?.id || 0}:${Number(user?.tenant_id || 0)}` @@ -327,7 +327,7 @@ export default function Layout({ children }: LayoutProps) { setShowAccountSwitcher(true) setIsUserMenuOpen(false) }} - className="flex w-full items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 justify-start" + className="flex w-full items-center justify-start rounded-none px-4 py-3 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700" > {t('common.switchAccount')} @@ -338,7 +338,7 @@ export default function Layout({ children }: LayoutProps) { setIsUserMenuOpen(false) handleLogout() }} - className="flex w-full items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 justify-start" + className="flex w-full items-center justify-start rounded-none px-4 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-700" > {t('common.logout')} @@ -403,7 +403,7 @@ export default function Layout({ children }: LayoutProps) { {isUserMenuOpen && ( -

+

{user?.nickname || t('common.user')}

{user?.email}

@@ -430,7 +430,7 @@ export default function Layout({ children }: LayoutProps) { setShowAccountSwitcher(true) setIsUserMenuOpen(false) }} - className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 justify-start" + className="flex w-full items-center justify-start rounded-none px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700" > {t('common.switchAccount')} @@ -439,7 +439,7 @@ export default function Layout({ children }: LayoutProps) { {t('common.logout')} @@ -464,11 +464,21 @@ export default function Layout({ children }: LayoutProps) { open={showAccountSwitcher} onClose={() => setShowAccountSwitcher(false)} currentScope="user" + currentTenantId={Number(user?.tenant_id || 0)} currentUserId={Number(user?.id || 0)} currentSessionKey={currentSessionKey} + currentUserTenants={tenants.map((tenant) => ({ + id: Number(tenant?.id || 0), + name: tenant?.name, + code: tenant?.code, + role: tenant?.role, + metadata: tenant?.metadata, + }))} consoleUserUrl={consoleUserUrl} consoleTenantUrl={consoleTenantUrl} consoleAdminUrl={consoleAdminUrl} + onSwitchSession={switchAccount} + onSwitchTenantIdentity={switchTenantIdentity} /> void - currentScope: ConsoleTarget + currentScope: ConsoleTarget | 'user' currentTenantId?: number currentUserId?: number currentSessionKey?: string + currentUserTenants?: Array<{ + id: number + name?: string + code?: string + role?: string + metadata?: Record + }> consoleUserUrl?: string consoleTenantUrl?: string consoleAdminUrl?: string + onSwitchSession?: (sessionKey: string) => Promise | void + onSwitchTenantIdentity?: (tenantID: number) => Promise | void } type SessionAction = { id: string label: string - target: ConsoleTarget + kind: 'console' | 'session' | 'identity' + target?: ConsoleTarget tenantId?: number - href: string + href?: string +} + +function listTenantMemberships(session: UserConsoleSession) { + return Array.isArray(session.tenant_memberships) + ? session.tenant_memberships.filter((membership) => Number(membership?.id || 0) > 0) + : [] +} + +function listTenantConsoleMemberships(session: UserConsoleSession) { + const fromSession = listTenantMemberships(session).filter((membership) => { + const role = String(membership?.role || '').toLowerCase() + return ['owner', 'admin'].includes(role) + }) + + if (fromSession.length > 0) { + return fromSession + } + + if (session.tenant_id > 0) { + const fallbackRole = String(session.tenant_role || '').toLowerCase() + if (!['owner', 'admin'].includes(fallbackRole)) { + return [] + } + return [{ + id: session.tenant_id, + name: session.tenant_name, + code: session.tenant_code, + role: fallbackRole, + }] + } + + return [] +} + +function listTenantIdentityMemberships(session: UserConsoleSession) { + const fromSession = listTenantMemberships(session).filter((membership) => { + const role = String(membership?.role || '').toLowerCase() + return role === '' || ['owner', 'admin', 'member', 'user'].includes(role) + }) + + if (fromSession.length > 0) { + return fromSession + } + + if (session.tenant_id > 0) { + const fallbackRole = String(session.tenant_role || '').toLowerCase() + if (!['owner', 'admin', 'member', 'user'].includes(fallbackRole)) { + return [] + } + return [{ + id: session.tenant_id, + name: session.tenant_name, + code: session.tenant_code, + role: fallbackRole, + }] + } + + return [] } function canAccessTenantConsole(session: UserConsoleSession) { - const role = String(session.tenant_role || '').toLowerCase() - return session.tenant_id > 0 && ['owner', 'admin'].includes(role) + return listTenantConsoleMemberships(session).length > 0 } function canAccessAdminConsole(session: UserConsoleSession) { @@ -90,9 +157,12 @@ export default function ConsoleAccountSwitcherModal({ currentTenantId = 0, currentUserId = 0, currentSessionKey = '', + currentUserTenants = [], consoleUserUrl = '', consoleTenantUrl = '', consoleAdminUrl = '', + onSwitchSession, + onSwitchTenantIdentity, }: ConsoleAccountSwitcherModalProps) { const { t } = useI18n() const [switchingId, setSwitchingId] = useState(null) @@ -109,13 +179,32 @@ export default function ConsoleAccountSwitcherModal({ setCleanedCount(Math.max(0, original.length - active.length)) }, [open]) + const isCurrentSession = (session: UserConsoleSession) => { + if (currentSessionKey) { + return session.key === currentSessionKey + } + return currentUserId > 0 && session.user_id === currentUserId + } + const getActionsForSession = (session: UserConsoleSession): SessionAction[] => { const actions: SessionAction[] = [] + const tenantConsoleMemberships = listTenantConsoleMemberships(session) + const defaultTenant = tenantConsoleMemberships[0] + + if (currentScope === 'user') { + actions.push({ + id: `session:${session.key}`, + label: t('consoleSwitcher.actions.switchToThisSession'), + kind: 'session', + }) + return actions + } if (currentScope === 'tenant' && currentTenantId > 0) { actions.push({ id: `tenant:${session.key}:${currentTenantId}`, label: t('consoleSwitcher.actions.enterCurrentTenantPanel'), + kind: 'console', target: 'tenant', tenantId: currentTenantId, href: joinConsoleUrl(consoleTenantUrl, `tenant/dashboard?code=__CODE__`), @@ -126,6 +215,7 @@ export default function ConsoleAccountSwitcherModal({ actions.push({ id: `admin:${session.key}`, label: t('consoleSwitcher.actions.enterAdminPanel'), + kind: 'console', target: 'admin', href: joinConsoleUrl(consoleAdminUrl, 'admin/dashboard?code=__CODE__'), }) @@ -135,17 +225,19 @@ export default function ConsoleAccountSwitcherModal({ actions.push({ id: `admin:${session.key}`, label: t('consoleSwitcher.actions.switchToAdminPanel'), + kind: 'console', target: 'admin', href: joinConsoleUrl(consoleAdminUrl, 'admin/dashboard?code=__CODE__'), }) } - if (currentScope === 'admin' && canAccessTenantConsole(session)) { + if (currentScope === 'admin' && defaultTenant) { actions.push({ - id: `tenant:${session.key}:${session.tenant_id}`, + id: `tenant:${session.key}:${defaultTenant.id}`, label: t('consoleSwitcher.actions.switchToDefaultTenantPanel'), + kind: 'console', target: 'tenant', - tenantId: session.tenant_id, + tenantId: defaultTenant.id, href: joinConsoleUrl(consoleTenantUrl, 'tenant/dashboard?code=__CODE__'), }) } @@ -153,7 +245,11 @@ export default function ConsoleAccountSwitcherModal({ return actions } - const handleSwitch = async (session: UserConsoleSession, action: SessionAction) => { + const handleConsoleSwitch = async (session: UserConsoleSession, action: SessionAction) => { + if (action.kind !== 'console' || !action.target || !action.href) { + return + } + setSwitchingId(action.id) try { const { code } = await authorizeConsoleWithToken(session.token, action.target, action.tenantId) @@ -171,6 +267,130 @@ export default function ConsoleAccountSwitcherModal({ } } + const handleSessionSwitch = async (session: UserConsoleSession, action: SessionAction) => { + if (action.kind !== 'session') { + return + } + + if (!onSwitchSession) { + await uiAlert(t('consoleSwitcher.errors.sessionSwitchUnavailable'), t('consoleSwitcher.errors.switchFailedTitle')) + return + } + + setSwitchingId(action.id) + try { + await onSwitchSession(session.key) + onClose() + } catch (error: any) { + const message = error?.response?.data?.error || error?.message || t('consoleSwitcher.errors.switchFailed') + await uiAlert(message, t('consoleSwitcher.errors.switchFailedTitle')) + } finally { + setSwitchingId(null) + } + } + + const handleIdentitySwitch = async (action: SessionAction) => { + if (action.kind !== 'identity') { + return + } + + if (typeof action.tenantId !== 'number' || !onSwitchTenantIdentity) { + await uiAlert(t('consoleSwitcher.errors.identitySwitchUnavailable'), t('consoleSwitcher.errors.switchFailedTitle')) + return + } + + setSwitchingId(action.id) + try { + await onSwitchTenantIdentity(action.tenantId) + onClose() + } catch (error: any) { + const message = error?.response?.data?.error || error?.message || t('consoleSwitcher.errors.switchFailed') + await uiAlert(message, t('consoleSwitcher.errors.switchFailedTitle')) + } finally { + setSwitchingId(null) + } + } + + const handleAction = async (session: UserConsoleSession, action: SessionAction) => { + if (action.kind === 'session') { + await handleSessionSwitch(session, action) + return + } + if (action.kind === 'identity') { + await handleIdentitySwitch(action) + return + } + await handleConsoleSwitch(session, action) + } + + const currentSession = useMemo(() => { + if (currentScope !== 'user') { + return null + } + const foundByKey = currentSessionKey ? sessions.find((session) => session.key === currentSessionKey) : null + if (foundByKey) { + return foundByKey + } + if (currentUserId > 0) { + return sessions.find((session) => Number(session.user_id) === Number(currentUserId)) || null + } + return null + }, [currentScope, currentSessionKey, currentUserId, sessions]) + + const identityOptions = useMemo(() => { + if (currentScope !== 'user') { + return [] as Array<{ tenantId: number; label: string }> + } + + const options = new Map() + options.set(0, { tenantId: 0, label: t('consoleSwitcher.badges.platformAccount') }) + + currentUserTenants.forEach((tenant) => { + const roleFromMetadata = String(tenant?.metadata?.user_role || '').toLowerCase() + const role = roleFromMetadata || String(tenant?.role || '').toLowerCase() + const tenantID = Number(tenant?.id || 0) + if (tenantID <= 0) { + return + } + if (!['owner', 'admin', 'member', 'user'].includes(role)) { + return + } + options.set(tenantID, { + tenantId: tenantID, + label: tenant?.name || t('consoleSwitcher.badges.tenantFallback', { tenantId: tenantID }), + }) + }) + + if (options.size === 1 && currentSession) { + listTenantIdentityMemberships(currentSession).forEach((membership) => { + const tenantID = Number(membership?.id || 0) + if (tenantID <= 0) { + return + } + options.set(tenantID, { + tenantId: tenantID, + label: membership?.name || t('consoleSwitcher.badges.tenantFallback', { tenantId: tenantID }), + }) + }) + } + + return Array.from(options.values()).sort((a, b) => { + if (a.tenantId === 0) return -1 + if (b.tenantId === 0) return 1 + return a.tenantId - b.tenantId + }) + }, [currentScope, currentSession, currentUserTenants, t]) + + const accountSessions = useMemo(() => { + if (currentScope !== 'user') { + return sessions + } + if (currentUserId <= 0) { + return sessions + } + return sessions.filter((session) => Number(session.user_id) !== Number(currentUserId)) + }, [currentScope, currentUserId, sessions]) + const handleSignOut = async (session: UserConsoleSession) => { const isCurrentConsoleUser = currentSessionKey ? session.key === currentSessionKey @@ -192,7 +412,7 @@ export default function ConsoleAccountSwitcherModal({ const actionable: UserConsoleSession[] = [] const unavailable: UserConsoleSession[] = [] - sessions.forEach((session) => { + accountSessions.forEach((session) => { if (getActionsForSession(session).length > 0) { actionable.push(session) } else { @@ -204,7 +424,16 @@ export default function ConsoleAccountSwitcherModal({ { key: 'actionable', title: t('consoleSwitcher.groups.actionable.title'), description: t('consoleSwitcher.groups.actionable.description'), sessions: actionable }, { key: 'unavailable', title: t('consoleSwitcher.groups.unavailable.title'), description: t('consoleSwitcher.groups.unavailable.description'), sessions: unavailable }, ].filter((group) => group.sessions.length > 0) - }, [sessions, currentScope, currentTenantId, currentUserId, consoleTenantUrl, consoleAdminUrl, t]) + }, [accountSessions, currentScope, currentTenantId, consoleTenantUrl, consoleAdminUrl, t]) + + const scopeTitle = currentScope === 'admin' + ? t('consoleSwitcher.switchTitleAdmin') + : currentScope === 'tenant' + ? t('consoleSwitcher.switchTitleTenant') + : t('consoleSwitcher.switchTitleUser') + + const showIdentitySection = currentScope === 'user' && identityOptions.length > 0 + const showSessionSection = currentScope === 'user' && accountSessions.length > 0 return ( -
+
{cleanedCount > 0 ? (
{t('consoleSwitcher.cleanedNotice', { count: cleanedCount })}
) : null} -
-
-
- -
-
-
- {currentScope === 'admin' ? t('consoleSwitcher.switchTitleAdmin') : t('consoleSwitcher.switchTitleTenant')} -
-
- {t('consoleSwitcher.switchDescription')} -
+ +
+
+
+ + + + {scopeTitle}
+ + {currentScope} +
+
{t('consoleSwitcher.switchDescription')}
- {sessions.length === 0 ? ( + {showIdentitySection ? ( +
+
+
{t('consoleSwitcher.sections.identityTitle')}
+
{t('consoleSwitcher.sections.identityDescription')}
+
+ {identityOptions.map((option) => { + const isCurrentPerspective = Number(option.tenantId) === Number(currentTenantId || 0) + return ( +
+
+
+
+
+
{option.label}
+
+ {option.tenantId === 0 + ? t('consoleSwitcher.badges.platformAccount') + : t('consoleSwitcher.badges.tenantFallback', { tenantId: option.tenantId })} +
+
+ void handleIdentitySwitch({ + id: `identity:current:${option.tenantId}`, + label: option.label, + kind: 'identity', + tenantId: option.tenantId, + })} + > + {isCurrentPerspective + ? t('consoleSwitcher.currentInUse') + : option.tenantId === 0 + ? t('consoleSwitcher.actions.switchToGlobalIdentity') + : t('consoleSwitcher.actions.switchToTenantIdentity', { tenant: option.label })} + +
+
+
+ ) + })} +
+ ) : null} + + {showIdentitySection && showSessionSection ? ( +
+ ) : null} + + {currentScope === 'user' ? ( + showSessionSection ? ( +
+
+
{t('consoleSwitcher.sections.accountsTitle')}
+
{t('consoleSwitcher.sections.accountsDescription')}
+
+ + {accountSessions.map((session) => { + const actions = getActionsForSession(session) + const displayName = session.nickname || session.email + const isCurrentUser = isCurrentSession(session) + const badges = getSessionBadges(session, t) + + return ( +
+
+
+
+
+
+ {session.avatar_url ? ( + {displayName} + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+
+ {displayName} + {isCurrentUser ? ( + + {t('consoleSwitcher.currentInUse')} + + ) : null} +
+
{session.email}
+
+
+ +
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+
+ +
+ {actions.length > 0 ? actions.map((action) => ( + void handleAction(session, action)} + > + + + + {action.label} + + )) : null} + + void handleSignOut(session)} + className="w-full justify-center text-slate-500 hover:text-red-600 sm:w-auto" + > + {t('consoleSwitcher.actions.signOutAccount')} + +
+
+
+
+ ) + })} +
+ ) : !showIdentitySection ? ( +
+ {t('consoleSwitcher.empty')} +
+ ) : null + ) : accountSessions.length === 0 ? (
{t('consoleSwitcher.empty')}
@@ -255,20 +645,18 @@ export default function ConsoleAccountSwitcherModal({ {group.sessions.map((session) => { const actions = getActionsForSession(session) const displayName = session.nickname || session.email - const isCurrentUser = currentSessionKey - ? session.key === currentSessionKey - : currentUserId > 0 && session.user_id === currentUserId + const isCurrentUser = isCurrentSession(session) const badges = getSessionBadges(session, t) return (
-
+
@@ -308,16 +696,17 @@ export default function ConsoleAccountSwitcherModal({
-
+
{actions.length > 0 ? actions.map((action) => ( void handleSwitch(session, action)} + onClick={() => void handleAction(session, action)} > @@ -332,7 +721,7 @@ export default function ConsoleAccountSwitcherModal({ variant="ghost" disabled={!!switchingId} onClick={() => void handleSignOut(session)} - className="text-slate-500 hover:text-red-600" + className="w-full justify-center text-slate-500 hover:text-red-600 sm:w-auto" > {t('consoleSwitcher.actions.signOutAccount')} diff --git a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx index 0982fd96..aec61b7d 100644 --- a/basaltpass-frontend/src/shared/contexts/AuthContext.tsx +++ b/basaltpass-frontend/src/shared/contexts/AuthContext.tsx @@ -50,6 +50,7 @@ interface AuthContextType { logout: () => void checkAuth: () => Promise switchAccount: (sessionKey: string) => Promise + switchTenantIdentity: (tenantID: number) => Promise } const AuthContext = createContext(undefined) @@ -248,6 +249,36 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, [loadIdentity, navigate, syncUserSessions]) + const switchTenantIdentity = useCallback(async (tenantID: number) => { + if (expectedScope !== 'user') { + throw new Error('Tenant identity switching is only available in user console') + } + + const previousToken = getAccessToken() + setIsLoading(true) + try { + const response = await client.post('/api/v1/auth/identity/switch', { + tenant_id: tenantID, + }) + const nextToken = String(response.data?.access_token || '').trim() + if (!nextToken) { + throw new Error('Missing access token from identity switch response') + } + + await loadIdentity(nextToken) + setHasChecked(true) + } catch (error) { + if (previousToken) { + setAccessToken(previousToken) + } else { + clearAccessToken() + } + throw error + } finally { + setIsLoading(false) + } + }, [expectedScope, loadIdentity]) + const logout = useCallback(() => { debugAuth.log('Logout called') if (expectedScope === 'user' && user?.id) { @@ -326,13 +357,15 @@ export function AuthProvider({ children }: AuthProviderProps) { }, [user, isLoading, hasChecked]) const tenantRole = (user?.tenant_role || '').toLowerCase() - const canManageCurrentTenant = user?.tenant_id ? user.tenant_id > 0 && ['owner', 'admin'].includes(tenantRole) : false - const canManageAnyTenant = tenants.some((tenant) => { + const canAccessCurrentTenant = user?.tenant_id + ? user.tenant_id > 0 && ['owner', 'admin'].includes(tenantRole) + : false + const canAccessAnyTenant = tenants.some((tenant) => { const roleFromMetadata = String(tenant?.metadata?.user_role || '').toLowerCase() const role = roleFromMetadata || String(tenant?.role || '').toLowerCase() return Number(tenant?.id || 0) > 0 && ['owner', 'admin'].includes(role) }) - const canManageTenant = canManageCurrentTenant || canManageAnyTenant + const canAccessTenantConsole = canAccessCurrentTenant || canAccessAnyTenant const canUseWallet = !!user && (Boolean(user.has_tenant) || Number(user.tenant_id || 0) > 0 || tenants.length > 0) const value: AuthContextType = { @@ -340,7 +373,7 @@ export function AuthProvider({ children }: AuthProviderProps) { tenants, userSessions, canAccessAdmin: !!user?.is_super_admin, - canAccessTenant: canManageTenant, + canAccessTenant: canAccessTenantConsole, canUseWallet, isAuthenticated: !!user, isLoading, @@ -348,6 +381,7 @@ export function AuthProvider({ children }: AuthProviderProps) { logout, checkAuth, switchAccount, + switchTenantIdentity, } return ( diff --git a/basaltpass-frontend/src/shared/generated/buildInfo.ts b/basaltpass-frontend/src/shared/generated/buildInfo.ts index b24c5d34..9f2df5e2 100644 --- a/basaltpass-frontend/src/shared/generated/buildInfo.ts +++ b/basaltpass-frontend/src/shared/generated/buildInfo.ts @@ -1,6 +1,6 @@ export const buildInfo = { version: "deploy-prod-v0.1.28", - commit: "a938ea61", - generatedAt: "2026-04-18T07:50:33.847Z", + commit: "0cef72ff", + generatedAt: "2026-04-18T22:33:39.553Z", copyrightStartYear: 2024, } as const diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index 2ee629b6..71152b73 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -390,10 +390,14 @@ const en = { actions: { loginToJoin: 'Sign in and continue', joinNow: 'Join this tenant', + confirmJoin: 'Confirm and join this tenant', joining: 'Joining...', goTenantLogin: 'Go to tenant sign in', goUserDashboard: 'Back to user dashboard', }, + confirm: { + prompt: 'Confirm joining tenant "{{tenantName}}"?', + }, success: { title: 'Tenant join successful', joinedPrefix: 'Your account has joined', @@ -5891,6 +5895,7 @@ const en = { cleanedNotice: '{{count}} expired historical account sessions were cleaned automatically. Sign in again to switch with them.', switchTitleAdmin: 'Admin console switch', switchTitleTenant: 'Tenant console switch', + switchTitleUser: 'Account and identity switch', switchDescription: 'The system reuses existing user sessions in this browser and issues matching tokens and cookies for the target console.', empty: 'No usable user session was found in this browser. Please sign in to the target account in User Console first.', groups: { @@ -5903,6 +5908,12 @@ const en = { description: 'These accounts are signed in, but no matching switch entry is available for the current target.', }, }, + sections: { + identityTitle: 'Identity perspectives', + identityDescription: 'Switch your current account perspective without leaving User Console.', + accountsTitle: 'Other signed-in accounts', + accountsDescription: 'Switch to another already signed-in account session on this browser.', + }, badges: { platformAdmin: 'Platform Admin', tenantFallback: 'Tenant {{tenantId}}', @@ -5914,11 +5925,16 @@ const en = { enterAdminPanel: 'Open admin panel', switchToAdminPanel: 'Switch to admin panel', switchToDefaultTenantPanel: 'Switch to default tenant panel', + switchToThisSession: 'Switch to this signed-in account', + switchToTenantIdentity: 'Switch identity to tenant: {{tenant}}', + switchToGlobalIdentity: 'Switch identity to global account', signOutAccount: 'Sign out account', }, noAvailableAction: 'No available console switch entry for now', errors: { sessionExpired: 'This account session has expired and was marked as signed out. Please sign in again for that account.', + sessionSwitchUnavailable: 'Session switching is only available in User Console.', + identitySwitchUnavailable: 'Identity switching is only available in User Console.', switchFailed: 'Account switch failed. Please sign in to the target account again.', switchFailedTitle: 'Unable to switch account', }, diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index 53bd7a67..c04f91b4 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -390,10 +390,14 @@ const en = { actions: { loginToJoin: '登录并继续', joinNow: '加入该租户', + confirmJoin: '确认加入该租户', joining: '加入中...', goTenantLogin: '前往租户登录', goUserDashboard: '返回用户控制台', }, + confirm: { + prompt: '确认加入租户“{{tenantName}}”吗?', + }, success: { title: '加入租户成功', joinedPrefix: '你的账号已成功加入', @@ -5891,6 +5895,7 @@ const en = { cleanedNotice: '{{count}} expired historical account sessions were cleaned automatically. Sign in again to switch with them.', switchTitleAdmin: 'Admin console switch', switchTitleTenant: 'Tenant console switch', + switchTitleUser: 'Account and identity switch', switchDescription: 'The system reuses existing user sessions in this browser and issues matching tokens and cookies for the target console.', empty: 'No usable user session was found in this browser. Please sign in to the target account in User Console first.', groups: { @@ -5903,6 +5908,12 @@ const en = { description: 'These accounts are signed in, but no matching switch entry is available for the current target.', }, }, + sections: { + identityTitle: '身份视角', + identityDescription: '在不离开用户控制台的情况下切换当前账号的身份视角。', + accountsTitle: '其他已登录账号', + accountsDescription: '切换到当前浏览器中已登录的其他账号会话。', + }, badges: { platformAdmin: 'Platform Admin', tenantFallback: 'Tenant {{tenantId}}', @@ -5914,11 +5925,16 @@ const en = { enterAdminPanel: 'Open admin panel', switchToAdminPanel: 'Switch to admin panel', switchToDefaultTenantPanel: 'Switch to default tenant panel', + switchToThisSession: 'Switch to this signed-in account', + switchToTenantIdentity: 'Switch identity to tenant: {{tenant}}', + switchToGlobalIdentity: 'Switch identity to global account', signOutAccount: 'Sign out account', }, noAvailableAction: 'No available console switch entry for now', errors: { sessionExpired: 'This account session has expired and was marked as signed out. Please sign in again for that account.', + sessionSwitchUnavailable: 'Session switching is only available in User Console.', + identitySwitchUnavailable: 'Identity switching is only available in User Console.', switchFailed: 'Account switch failed. Please sign in to the target account again.', switchFailedTitle: 'Unable to switch account', }, diff --git a/basaltpass-frontend/src/shared/utils/userSessions.ts b/basaltpass-frontend/src/shared/utils/userSessions.ts index d79338e7..9a37d363 100644 --- a/basaltpass-frontend/src/shared/utils/userSessions.ts +++ b/basaltpass-frontend/src/shared/utils/userSessions.ts @@ -7,6 +7,12 @@ export interface UserConsoleSession { user_id: number tenant_id: number tenant_code?: string + tenant_memberships?: Array<{ + id: number + name?: string + code?: string + role?: string + }> email: string nickname?: string avatar_url?: string @@ -31,6 +37,8 @@ interface SessionTenantLike { id: number name?: string code?: string + role?: string + metadata?: Record } function isBrowser() { @@ -117,6 +125,14 @@ export function upsertUserConsoleSession( const tenantID = Number(profile.tenant_id || 0) const key = buildSessionKey(profile.id, tenantID) const currentTenant = tenantID > 0 ? tenants.find((tenant) => Number(tenant.id) === tenantID) : null + const tenantMemberships = tenants + .map((tenant) => ({ + id: Number(tenant.id || 0), + name: tenant.name, + code: tenant.code, + role: String(tenant.metadata?.user_role || tenant.role || '').toLowerCase(), + })) + .filter((tenant) => tenant.id > 0) const sessions = readSessions().filter((session) => session.key !== key) sessions.unshift({ @@ -124,6 +140,7 @@ export function upsertUserConsoleSession( user_id: profile.id, tenant_id: tenantID, tenant_code: currentTenant?.code, + tenant_memberships: tenantMemberships, email: profile.email, nickname: profile.nickname, avatar_url: profile.avatar_url, From 991811faa18ab3d810e00d522f2897cd0763e73e Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 18 Apr 2026 22:59:09 -0700 Subject: [PATCH 11/21] refactor(frontend): unify tenant/app user-permission management UI and abstractions --- .../tenant/app/AppPermissionManagement.tsx | 295 +++--- .../features/tenant/app/AppRoleManagement.tsx | 238 ++--- .../features/tenant/app/AppUserManagement.tsx | 438 ++++----- .../permission/TenantPermissionManagement.tsx | 168 ++-- .../features/tenant/user/RoleManagement.tsx | 146 +-- .../features/tenant/user/UserManagement.tsx | 877 +++++++----------- .../src/shared/hooks/useClientPagination.ts | 64 ++ .../shared/hooks/useManagedPaginationBar.ts | 61 ++ .../src/shared/i18n/messages/en.ts | 6 + .../src/shared/i18n/messages/zh.ts | 6 + .../src/shared/ui/PCompactPaginationBar.tsx | 40 + .../src/shared/ui/PManagedTableSection.tsx | 70 ++ .../src/shared/ui/PManagementFilterCard.tsx | 37 + .../shared/ui/PManagementPageContainer.tsx | 32 + .../src/shared/ui/PManagementStatsGrid.tsx | 38 + .../src/shared/ui/PManagementToast.tsx | 31 + basaltpass-frontend/src/shared/ui/index.ts | 6 + 17 files changed, 1342 insertions(+), 1211 deletions(-) create mode 100644 basaltpass-frontend/src/shared/hooks/useClientPagination.ts create mode 100644 basaltpass-frontend/src/shared/hooks/useManagedPaginationBar.ts create mode 100644 basaltpass-frontend/src/shared/ui/PCompactPaginationBar.tsx create mode 100644 basaltpass-frontend/src/shared/ui/PManagedTableSection.tsx create mode 100644 basaltpass-frontend/src/shared/ui/PManagementFilterCard.tsx create mode 100644 basaltpass-frontend/src/shared/ui/PManagementPageContainer.tsx create mode 100644 basaltpass-frontend/src/shared/ui/PManagementStatsGrid.tsx create mode 100644 basaltpass-frontend/src/shared/ui/PManagementToast.tsx diff --git a/basaltpass-frontend/src/features/tenant/app/AppPermissionManagement.tsx b/basaltpass-frontend/src/features/tenant/app/AppPermissionManagement.tsx index eb8373cd..19ce3ff4 100644 --- a/basaltpass-frontend/src/features/tenant/app/AppPermissionManagement.tsx +++ b/basaltpass-frontend/src/features/tenant/app/AppPermissionManagement.tsx @@ -1,20 +1,21 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { uiAlert, uiConfirm, uiPrompt } from '@contexts/DialogContext' +import React, { useEffect, useMemo, useState } from 'react' +import { uiAlert, uiConfirm } from '@contexts/DialogContext' import { useParams, useNavigate } from 'react-router-dom' import { KeyIcon, PlusIcon, PencilIcon, TrashIcon, - MagnifyingGlassIcon, XMarkIcon, ExclamationTriangleIcon, TagIcon, - LockClosedIcon + FunnelIcon } from '@heroicons/react/24/outline' import TenantLayout from '@features/tenant/components/TenantLayout' -import { PSkeleton, PInput, PSelect, PTextarea, PButton, PPageHeader } from '@ui' -import PTable, { type PTableColumn, type PTableAction } from '@ui/PTable' +import useClientPagination from '@hooks/useClientPagination' +import useManagedPaginationBar from '@hooks/useManagedPaginationBar' +import { PSkeleton, PInput, PSelect, PTextarea, PButton, PPageHeader, PBadge, PManagementFilterCard, PManagedTableSection, PManagementPageContainer } from '@ui' +import { type PTableColumn, type PTableAction } from '@ui/PTable' import { tenantAppApi } from '@api/tenant/tenantApp' import userPermissionsApi, { type Permission } from '@api/tenant/appPermissions' import useDebounce from '@hooks/useDebounce' @@ -24,6 +25,7 @@ export default function AppPermissionManagement() { const { t, locale } = useI18n() const { id: appId } = useParams<{ id: string }>() const navigate = useNavigate() + const pageSize = 20 const [app, setApp] = useState(null) const [permissions, setPermissions] = useState([]) @@ -151,35 +153,93 @@ export default function AppPermissionManagement() { } } - // - const getCategories = () => { - const categories = Array.from(new Set(permissions.map(p => p.category))) - return categories.sort() - } + const categories = useMemo(() => { + return Array.from(new Set(permissions.map(p => p.category))).sort() + }, [permissions]) // - const filteredPermissions = permissions.filter(permission => { - const matchesSearch = debouncedSearchTerm === '' || - permission.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - permission.code.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (permission.description && permission.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - - const matchesCategory = selectedCategory === '' || permission.category === selectedCategory - - return matchesSearch && matchesCategory - }) + const filteredPermissions = useMemo(() => { + return permissions.filter(permission => { + const matchesSearch = debouncedSearchTerm === '' || + permission.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + permission.code.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (permission.description && permission.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - // - const getPermissionsByCategory = () => { - const categories: { [key: string]: Permission[] } = {} - filteredPermissions.forEach(permission => { - if (!categories[permission.category]) { - categories[permission.category] = [] - } - categories[permission.category].push(permission) + const matchesCategory = selectedCategory === '' || permission.category === selectedCategory + return matchesSearch && matchesCategory }) - return categories - } + }, [permissions, debouncedSearchTerm, selectedCategory]) + const { + currentPage, + pageItems: paginatedPermissions, + totalItems, + setPage, + resetPage, + } = useClientPagination(filteredPermissions, pageSize) + + const paginationBar = useManagedPaginationBar({ + currentPage, + pageSize, + totalItems, + onPageChange: setPage, + summary: ({ start, end, total }) => t('tenantRoleManagement.pagination.summary', { start, end, total }), + pageInfo: ({ currentPage: page, totalPages }) => t('tenantRoleManagement.pagination.pageInfo', { current: page, total: totalPages }), + }) + + useEffect(() => { + resetPage() + }, [debouncedSearchTerm, selectedCategory, resetPage]) + + const columns: PTableColumn[] = [ + { + key: 'code', + title: t('tenantAppPermissionManagement.table.permissionCode'), + sortable: true, + render: (row) =>
{row.code}
+ }, + { + key: 'name', + title: t('tenantAppPermissionManagement.table.permissionName'), + sortable: true, + render: (row) =>
{row.name}
+ }, + { + key: 'category', + title: t('tenantAppPermissionManagement.table.category'), + sortable: true, + render: (row) => }>{row.category} + }, + { + key: 'description', + title: t('tenantAppPermissionManagement.table.description'), + render: (row) =>
{row.description || '-'}
+ }, + { + key: 'created_at', + title: t('tenantAppPermissionManagement.table.createdAt'), + align: 'right', + sortable: true, + sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + render: (row) => new Date(row.created_at).toLocaleString(locale) + } + ] + + const actions: PTableAction[] = [ + { + key: 'edit', + label: t('tenantAppPermissionManagement.actions.edit'), + icon: , + variant: 'secondary', + onClick: (row) => handleEditPermission(row) + }, + { + key: 'delete', + label: t('tenantAppPermissionManagement.actions.delete'), + icon: , + variant: 'danger', + onClick: (row) => handleDeletePermission(row) + } + ] if (loading) { return ( @@ -209,117 +269,61 @@ export default function AppPermissionManagement() { return ( -
- {/* */} - } - actions={ -
- navigate(`/tenant/apps/${appId}/roles`)}>{t('tenantAppPermissionManagement.actions.roleManagement')} - navigate(`/tenant/apps/${appId}/users`)}>{t('tenantAppPermissionManagement.actions.userManagement')} - }>{t('tenantAppPermissionManagement.actions.createPermission')} -
- } - /> - - {/* */} -
-
-
- setSearchTerm((e.target as HTMLInputElement).value)} - icon={} - /> -
-
- setSelectedCategory((e.target as HTMLSelectElement).value)} - > - - {getCategories().map(category => ( - - ))} - -
-
-
- - {/* ( PTable)*/} -
- - data={filteredPermissions} - rowKey={(row) => String(row.id)} - loading={loading} - emptyText={searchTerm || selectedCategory ? t('tenantAppPermissionManagement.empty.searchNoResult') : t('tenantAppPermissionManagement.empty.noPermission')} - emptyContent={!searchTerm && !selectedCategory ? ( - }>{t('tenantAppPermissionManagement.actions.createPermission')} - ) : undefined} - size="md" - striped - defaultSort={{ key: 'name', order: 'asc' }} - columns={[ - { - key: 'name', - title: t('tenantAppPermissionManagement.table.permissionName'), - dataIndex: 'name', - sortable: true, - render: (row) => ( -
- -
-
{row.name}
-
{row.code}
-
-
- ) - }, - { - key: 'category', - title: t('tenantAppPermissionManagement.table.category'), - dataIndex: 'category', - sortable: true, - }, - { - key: 'description', - title: t('tenantAppPermissionManagement.table.description'), - dataIndex: 'description', - className: 'max-w-xl truncate', - }, - { - key: 'created_at', - title: t('tenantAppPermissionManagement.table.createdAt'), - dataIndex: 'created_at', - align: 'right', - sortable: true, - sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - render: (row) => new Date(row.created_at).toLocaleString(locale) - } - ]} - actions={[ - { - key: 'edit', - label: t('tenantAppPermissionManagement.actions.edit'), - icon: , - variant: 'secondary', - onClick: (row) => handleEditPermission(row) - }, - { - key: 'delete', - label: t('tenantAppPermissionManagement.actions.delete'), - icon: , - variant: 'danger', - confirm: t('tenantAppPermissionManagement.deleteActionConfirm'), - onClick: (row) => handleDeletePermission(row) - } - ]} + } + actions={ +
+ navigate(`/tenant/apps/${appId}/roles`)}>{t('tenantAppPermissionManagement.actions.roleManagement')} + navigate(`/tenant/apps/${appId}/users`)}>{t('tenantAppPermissionManagement.actions.userManagement')} + }>{t('tenantAppPermissionManagement.actions.createPermission')} +
+ } /> -
-
+ } + filter={ + + + setSelectedCategory((e.target as HTMLSelectElement).value)} + className="pl-10" + > + + {categories.map(category => ( + + ))} + +
+ } + /> + } + > + + data={paginatedPermissions} + columns={columns} + actions={actions} + rowKey={(row) => String(row.id)} + loading={loading} + emptyText={searchTerm || selectedCategory ? t('tenantAppPermissionManagement.empty.searchNoResult') : t('tenantAppPermissionManagement.empty.noPermission')} + emptyContent={!searchTerm && !selectedCategory ? ( + }>{t('tenantAppPermissionManagement.actions.createPermission')} + ) : undefined} + size="md" + striped + defaultSort={{ key: 'created_at', order: 'desc' }} + pagination={paginationBar} + /> + {/* */} {showCreateModal && ( @@ -352,8 +356,13 @@ export default function AppPermissionManagement() { // const PermissionModal: React.FC<{ title: string - formData: any - setFormData: (data: any) => void + formData: { + code: string + name: string + description: string + category: string + } + setFormData: (data: { code: string; name: string; description: string; category: string }) => void submitting: boolean onSubmit: () => void onClose: () => void diff --git a/basaltpass-frontend/src/features/tenant/app/AppRoleManagement.tsx b/basaltpass-frontend/src/features/tenant/app/AppRoleManagement.tsx index 5111d092..bbb46e45 100644 --- a/basaltpass-frontend/src/features/tenant/app/AppRoleManagement.tsx +++ b/basaltpass-frontend/src/features/tenant/app/AppRoleManagement.tsx @@ -1,12 +1,11 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { uiAlert, uiConfirm, uiPrompt } from '@contexts/DialogContext' +import React, { useEffect, useMemo, useState } from 'react' +import { uiAlert, uiConfirm } from '@contexts/DialogContext' import { useParams, useNavigate } from 'react-router-dom' import { ShieldCheckIcon, PlusIcon, PencilIcon, TrashIcon, - MagnifyingGlassIcon, XMarkIcon, KeyIcon, ExclamationTriangleIcon @@ -15,13 +14,17 @@ import TenantLayout from '@features/tenant/components/TenantLayout' import { tenantAppApi } from '@api/tenant/tenantApp' import { userPermissionsApi, type Permission, type Role } from '@api/tenant/appPermissions' import useDebounce from '@hooks/useDebounce' -import { PSkeleton, PPageHeader, PEmptyState, PButton, PInput, PTextarea, PBadge } from '@ui' +import useClientPagination from '@hooks/useClientPagination' +import useManagedPaginationBar from '@hooks/useManagedPaginationBar' +import { PSkeleton, PPageHeader, PEmptyState, PButton, PInput, PTextarea, PBadge, PManagementFilterCard, PManagedTableSection, PManagementPageContainer } from '@ui' +import { type PTableAction, type PTableColumn } from '@ui/PTable' import { useI18n } from '@shared/i18n' export default function AppRoleManagement() { const { t, locale } = useI18n() const { id: appId } = useParams<{ id: string }>() const navigate = useNavigate() + const pageSize = 10 const [app, setApp] = useState(null) const [roles, setRoles] = useState([]) @@ -153,12 +156,80 @@ export default function AppRoleManagement() { } } - const filteredRoles = roles.filter(role => - debouncedSearchTerm === '' || - role.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - role.code.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (role.description && role.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) + const filteredRoles = useMemo(() => { + return roles.filter(role => + debouncedSearchTerm === '' || + role.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + role.code.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (role.description && role.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + }, [roles, debouncedSearchTerm]) + const { + currentPage, + pageItems: paginatedRoles, + totalItems, + setPage, + resetPage, + } = useClientPagination(filteredRoles, pageSize) + + const paginationBar = useManagedPaginationBar({ + currentPage, + pageSize, + totalItems, + onPageChange: setPage, + summary: ({ start, end, total }) => t('tenantRoleManagement.pagination.summary', { start, end, total }), + pageInfo: ({ currentPage: page, totalPages }) => t('tenantRoleManagement.pagination.pageInfo', { current: page, total: totalPages }), + }) + + useEffect(() => { + resetPage() + }, [debouncedSearchTerm, resetPage]) + + const columns: PTableColumn[] = [ + { + key: 'role_info', + title: t('tenantAppRoleManagement.table.roleInfo'), + render: (role) => ( +
+
{role.name}
+
{role.code}
+ {role.description ?
{role.description}
: null} +
+ ) + }, + { + key: 'permission_count', + title: t('tenantAppRoleManagement.table.permissionCount'), + align: 'center', + render: (role) => ( + {t('tenantAppRoleManagement.fields.permissionCount', { count: role.permissions?.length || 0 })} + ) + }, + { + key: 'created_at', + title: t('tenantAppRoleManagement.table.createdAt'), + sortable: true, + sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + render: (role) => new Date(role.created_at).toLocaleDateString(locale) + } + ] + + const actions: PTableAction[] = [ + { + key: 'edit', + label: t('tenantAppRoleManagement.actions.edit'), + icon: , + variant: 'secondary', + onClick: (role) => handleEditRole(role) + }, + { + key: 'delete', + label: t('tenantAppRoleManagement.actions.delete'), + icon: , + variant: 'danger', + onClick: (role) => handleDeleteRole(role) + } + ] const getPermissionsByCategory = () => { const categories: { [key: string]: Permission[] } = {} @@ -199,117 +270,56 @@ export default function AppRoleManagement() { return ( -
- {/* */} - } - actions={ -
- navigate(`/tenant/apps/${appId}/permissions`)} leftIcon={}>{t('tenantAppRoleManagement.actions.permissionManagement')} - navigate(`/tenant/apps/${appId}/users`)}>{t('tenantAppRoleManagement.actions.userManagement')} - }>{t('tenantAppRoleManagement.actions.createRole')} -
- } - /> - - {/* */} -
-
- - setSearchTerm(e.target.value)} - className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500" - /> -
-
- - {/* */} -
-
-
-

- {t('tenantAppRoleManagement.list.title', { count: filteredRoles.length })} -

-
- - {filteredRoles.length === 0 ? ( + } + actions={ +
+ navigate(`/tenant/apps/${appId}/permissions`)} leftIcon={}>{t('tenantAppRoleManagement.actions.permissionManagement')} + navigate(`/tenant/apps/${appId}/users`)}>{t('tenantAppRoleManagement.actions.userManagement')} + }>{t('tenantAppRoleManagement.actions.createRole')} +
+ } + /> + } + filter={ + + } + > +
+ {filteredRoles.length === 0 && !loading ? ( +
- {!searchTerm && ( - }>{t('tenantAppRoleManagement.actions.createRole')} - )} + {!searchTerm ? }>{t('tenantAppRoleManagement.actions.createRole')} : null} - ) : ( -
- {filteredRoles.map((role) => ( -
-
-
- -
-

{role.name}

-

{t('tenantAppRoleManagement.fields.code')}: {role.code}

-
-
-
- handleEditRole(role)} - variant="ghost" - size="sm" - className="px-2 text-blue-600 hover:text-blue-800" - > - - - handleDeleteRole(role)} - variant="ghost" - size="sm" - className="px-2 text-red-600 hover:text-red-800" - > - - -
-
- - {role.description && ( -

{role.description}

- )} - -
-
- - {t('tenantAppRoleManagement.fields.permissionCount', { count: role.permissions?.length || 0 })} -
-
- {new Date(role.created_at).toLocaleDateString(locale)} -
-
- - {role.permissions && role.permissions.length > 0 && ( -
- {role.permissions.slice(0, 3).map((permission) => ( - {permission.name} - ))} - {role.permissions.length > 3 && ( - {t('tenantAppRoleManagement.fields.moreCount', { count: role.permissions.length - 3 })} - )} -
- )} -
- ))} -
- )} -
+
+ ) : ( + <> + + data={paginatedRoles} + columns={columns} + actions={actions} + rowKey={(row) => String(row.id)} + loading={loading} + emptyText={t('tenantAppRoleManagement.empty.noRole')} + defaultSort={{ key: 'created_at', order: 'desc' }} + pagination={paginationBar} + /> + + )}
-
+ {/* */} {showCreateModal && ( diff --git a/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx b/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx index d32550ee..a5b2c172 100644 --- a/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx +++ b/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { uiAlert, uiConfirm, uiPrompt } from '@contexts/DialogContext' +import React, { useEffect, useMemo, useState } from 'react' +import { uiAlert, uiConfirm } from '@contexts/DialogContext' import { useParams, useNavigate } from 'react-router-dom' import { UsersIcon, - MagnifyingGlassIcon, FunnelIcon, ExclamationTriangleIcon, NoSymbolIcon, @@ -15,15 +14,16 @@ import { ShieldCheckIcon, KeyIcon, PlusIcon, - XMarkIcon, - Cog6ToothIcon + XMarkIcon } from '@heroicons/react/24/outline' import TenantLayout from '@features/tenant/components/TenantLayout' import { tenantAppApi } from '@api/tenant/tenantApp' import { appUserApi, type AppUser, type AppUsersResponse } from '@api/tenant/appUser' import { userPermissionsApi, type Permission, type Role, type UserPermission, type UserRole } from '@api/tenant/appPermissions' import useDebounce from '@hooks/useDebounce' -import { PSkeleton, PBadge, PPagination, PPageHeader, PEmptyState, PButton, PCard } from '@ui' +import useManagedPaginationBar from '@hooks/useManagedPaginationBar' +import { PSkeleton, PBadge, PPageHeader, PButton, PManagementFilterCard, PManagedTableSection, PSelect, PManagementPageContainer, PTextarea } from '@ui' +import { type PTableColumn } from '@ui/PTable' import { useI18n } from '@shared/i18n' export default function AppUserManagement() { @@ -327,13 +327,136 @@ export default function AppUserManagement() { } } - const filteredUsers = users.filter(user => - debouncedSearchTerm === '' || - user.user_email.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (user.user_nickname && user.user_nickname.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) + const filteredUsers = useMemo(() => { + return users.filter(user => + debouncedSearchTerm === '' || + user.user_email.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (user.user_nickname && user.user_nickname.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + }, [users, debouncedSearchTerm]) + + const userColumns: PTableColumn[] = [ + { + key: 'user', + title: t('tenantAppUserManagement.table.user'), + render: (user) => ( +
+ {user.user_avatar ? ( + {user.user_nickname + ) : ( + + )} +
+
+ {user.user_nickname || t('tenantAppUserManagement.fields.nicknameNotSet')} +
+
+ + {user.user_email} +
+
+
+ ) + }, + { + key: 'status', + title: t('tenantAppUserManagement.table.status'), + render: (user) => ( +
+
+ {getStatusIcon(user.status)} + + {getStatusText(user.status)} + +
+ {user.ban_reason ? ( +
+ {t('tenantAppUserManagement.fields.reason')}: {user.ban_reason} +
+ ) : null} +
+ ) + }, + { + key: 'last_active_at', + title: t('tenantAppUserManagement.table.lastActive'), + render: (user) => ( + + {user.last_active_at + ? new Date(user.last_active_at).toLocaleString(locale) + : t('tenantAppUserManagement.fields.neverActive')} + + ) + }, + { + key: 'first_authorized_at', + title: t('tenantAppUserManagement.table.authorizedAt'), + sortable: true, + sorter: (a, b) => new Date(a.first_authorized_at).getTime() - new Date(b.first_authorized_at).getTime(), + render: (user) => ( + {new Date(user.first_authorized_at).toLocaleString(locale)} + ) + }, + { + key: 'scopes', + title: t('tenantAppUserManagement.table.scopes'), + render: (user) => ( +
+ {user.scopes || t('tenantAppUserManagement.fields.defaultScopes')} +
+ ) + }, + { + key: 'actions', + title: t('tenantAppUserManagement.table.actions'), + align: 'right', + render: (user) => ( +
+ handleManagePermissions(user)} + leftIcon={} + > + {t('tenantAppUserManagement.actions.permissions')} + + {user.status === 'active' ? ( + <> + handleUserAction(user, 'restrict')}> + {t('tenantAppUserManagement.actions.restrict')} + + handleUserAction(user, 'suspend')}> + {t('tenantAppUserManagement.actions.suspend')} + + handleUserAction(user, 'ban')}> + {t('tenantAppUserManagement.actions.ban')} + + + ) : ( + handleUserAction(user, 'unban')}> + {t('tenantAppUserManagement.actions.unban')} + + )} + handleRevokeAuthorization(user)}> + {t('tenantAppUserManagement.actions.revokeAuthorization')} + +
+ ) + } + ] - const totalPages = Math.ceil(totalUsers / pageSize) + const paginationBar = useManagedPaginationBar({ + currentPage, + pageSize, + totalItems: totalUsers, + onPageChange: setCurrentPage, + summary: ({ start, end, total }) => t('tenantRoleManagement.pagination.summary', { start, end, total }), + pageInfo: ({ currentPage: page, totalPages }) => t('tenantRoleManagement.pagination.pageInfo', { current: page, total: totalPages }), + }) if (loading && users.length === 0) { return ( @@ -363,220 +486,62 @@ export default function AppUserManagement() { return ( -
- {/* */} - } - actions={ -
- navigate(`/tenant/apps/${appId}/permissions`)} leftIcon={}>{t('tenantAppUserManagement.actions.permissionManagement')} - navigate(`/tenant/apps/${appId}/roles`)} leftIcon={}>{t('tenantAppUserManagement.actions.roleManagement')} - navigate(`/tenant/apps/${appId}`)}>{t('tenantAppUserManagement.actions.backToAppDetail')} -
- } - /> - - {/* */} - -
-
-
- - setSearchTerm(e.target.value)} - className="block w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500" - /> + } + actions={ +
+ navigate(`/tenant/apps/${appId}/permissions`)} leftIcon={}>{t('tenantAppUserManagement.actions.permissionManagement')} + navigate(`/tenant/apps/${appId}/roles`)} leftIcon={}>{t('tenantAppUserManagement.actions.roleManagement')} + navigate(`/tenant/apps/${appId}`)}>{t('tenantAppUserManagement.actions.backToAppDetail')}
-
-
+ } + /> + } + filter={ + { + setSearchTerm(value) + setCurrentPage(1) + }} + searchPlaceholder={t('tenantAppUserManagement.searchPlaceholder')} + rightContent={
- -
-
-
- - - {/* */} - -
-
-

- {t('tenantAppUserManagement.listTitle', { total: totalUsers })} -

-
- - {filteredUsers.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {filteredUsers.map((user) => ( - - - - - - - - - ))} - -
- {t('tenantAppUserManagement.table.user')} - - {t('tenantAppUserManagement.table.status')} - - {t('tenantAppUserManagement.table.lastActive')} - - {t('tenantAppUserManagement.table.authorizedAt')} - - {t('tenantAppUserManagement.table.scopes')} - - {t('tenantAppUserManagement.table.actions')} -
-
- {user.user_avatar ? ( - {user.user_nickname - ) : ( - - )} -
-
- {user.user_nickname || t('tenantAppUserManagement.fields.nicknameNotSet')} -
-
- - {user.user_email} -
-
-
-
-
- {getStatusIcon(user.status)} - - {getStatusText(user.status)} - -
- {user.ban_reason && ( -
- {t('tenantAppUserManagement.fields.reason')}: {user.ban_reason} -
- )} -
- {user.last_active_at ? ( - new Date(user.last_active_at).toLocaleString(locale) - ) : ( - t('tenantAppUserManagement.fields.neverActive') - )} - - {new Date(user.first_authorized_at).toLocaleString(locale)} - -
- {user.scopes || t('tenantAppUserManagement.fields.defaultScopes')} -
-
-
- - {user.status === 'active' ? ( - <> - - - - - ) : ( - - )} - -
-
-
- )} - - {/* */} - {totalPages > 1 && ( -
- +
- )} -
-
-
+ } + /> + } + > + + data={filteredUsers} + columns={userColumns} + rowKey={(row: AppUser) => row.id} + emptyText={searchTerm ? t('tenantAppUserManagement.empty.searchNoResult') : t('tenantAppUserManagement.empty.noAuthorizedUser')} + size="md" + striped + defaultSort={{ key: 'first_authorized_at', order: 'desc' }} + pagination={paginationBar} + /> + {/* */} {showActionModal && selectedUser && ( @@ -594,11 +559,10 @@ export default function AppUserManagement() { -