diff --git a/openapi/oauth/types/oidc.go b/openapi/oauth/types/oidc.go index 9b8d6563..126c636c 100644 --- a/openapi/oauth/types/oidc.go +++ b/openapi/oauth/types/oidc.go @@ -98,6 +98,9 @@ func (user OIDCUserInfo) Map() map[string]interface{} { } // Add Yao custom fields with namespace + if user.YaoUserID != "" { + result["yao:user_id"] = user.YaoUserID + } if user.YaoTenantID != "" { result["yao:tenant_id"] = user.YaoTenantID } diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index cb223789..eb215d3c 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -987,6 +987,7 @@ func createTestTeam(t *testing.T, serverURL, baseURL, accessToken, teamName stri createTeamBody := map[string]interface{}{ "name": teamName, "description": "Team created for testing purposes", + "role_id": "system:root", // Use system:root role which includes all scopes } bodyBytes, err := json.Marshal(createTeamBody) @@ -2037,4 +2038,285 @@ func TestMemberUpdateRobot(t *testing.T) { } } +// TestMemberProfileUpdate tests the PUT /user/teams/:team_id/members/:user_id/profile endpoint +func TestMemberProfileUpdate(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member Profile Update Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token with root permission and explicit member profile scope + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile system:root member:profile:update:own") + + // Create a test team + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member Profile Update Test Team") + teamID := getTeamID(createdTeam) + + // The creator is automatically a member, so we can use their user_id + userID := tokenInfo.UserID + + testCases := []struct { + name string + teamID string + userID string + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + validateFn func(*testing.T, string) // Optional validation function with userID + }{ + { + "update profile without authentication", + teamID, + userID, + map[string]interface{}{ + "display_name": "New Name", + }, + map[string]string{}, + 401, + "should require authentication", + nil, + }, + { + "update display_name", + teamID, + userID, + map[string]interface{}{ + "display_name": "Updated Display Name", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update display_name successfully", + func(t *testing.T, uid string) { + // Verify the update by getting member details + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "Updated Display Name", member["display_name"]) + }, + }, + { + "update bio", + teamID, + userID, + map[string]interface{}{ + "bio": "This is my updated bio", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update bio successfully", + func(t *testing.T, uid string) { + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "This is my updated bio", member["bio"]) + }, + }, + { + "update avatar", + teamID, + userID, + map[string]interface{}{ + "avatar": "https://example.com/avatar.png", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update avatar successfully", + func(t *testing.T, uid string) { + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "https://example.com/avatar.png", member["avatar"]) + }, + }, + { + "update email", + teamID, + userID, + map[string]interface{}{ + "email": "newemail@example.com", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update email successfully", + func(t *testing.T, uid string) { + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "newemail@example.com", member["email"]) + }, + }, + { + "update all fields at once", + teamID, + userID, + map[string]interface{}{ + "display_name": "Complete Update", + "bio": "All fields updated", + "avatar": "https://example.com/complete.png", + "email": "complete@example.com", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update all fields successfully", + func(t *testing.T, uid string) { + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "Complete Update", member["display_name"]) + assert.Equal(t, "All fields updated", member["bio"]) + assert.Equal(t, "https://example.com/complete.png", member["avatar"]) + assert.Equal(t, "complete@example.com", member["email"]) + }, + }, + { + "update with empty body", + teamID, + userID, + map[string]interface{}{}, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should reject empty update", + nil, + }, + { + "update other user's profile should fail", + teamID, + "other-user-id", + map[string]interface{}{ + "display_name": "Should Fail", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent user (member not found)", + nil, + }, + { + "update profile in non-existent team", + "non-existent-team-id", + userID, + map[string]interface{}{ + "display_name": "Should Fail", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + nil, + }, + { + "update with invalid JSON", + teamID, + userID, + nil, // Will send invalid JSON + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should handle invalid JSON", + nil, + }, + { + "partial update - single field", + teamID, + userID, + map[string]interface{}{ + "display_name": "Partial Update", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should handle partial update with single field", + func(t *testing.T, uid string) { + provider := testutils.GetUserProvider(t) + member, err := provider.GetMember(context.Background(), teamID, uid) + assert.NoError(t, err) + assert.Equal(t, "Partial Update", member["display_name"]) + // Other fields should remain unchanged + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/" + tc.userID + "/profile" + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON for invalid JSON test case + req, err = http.NewRequest("PUT", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("PUT", requestURL, bytes.NewBuffer(bodyBytes)) + } + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/json") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 200 { + // Parse response as success message + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + assert.Contains(t, response, "user_id", "Should have user_id") + assert.Contains(t, response, "message", "Should have success message") + assert.Equal(t, tc.userID, response["user_id"], "Should have correct user_id") + assert.Equal(t, "Member profile updated successfully", response["message"], "Should have correct success message") + + // Run custom validation if provided + if tc.validateFn != nil { + tc.validateFn(t, tc.userID) + } + } + + t.Logf("Member profile update test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + // Note: getTeamID function is already defined in team_test.go diff --git a/openapi/tests/user/profile_test.go b/openapi/tests/user/profile_test.go new file mode 100644 index 00000000..550e4c3c --- /dev/null +++ b/openapi/tests/user/profile_test.go @@ -0,0 +1,811 @@ +package user_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestProfileGet tests the GET /user/profile endpoint +func TestProfileGet(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client + client := testutils.RegisterTestClient(t, "Profile Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + + // Create a test user with root permissions + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile email") + + // Test 1: Get basic profile without optional parameters + t.Run("GetBasicProfile", func(t *testing.T) { + fullURL := serverURL + baseURL + "/user/profile" + t.Logf("Requesting URL: %s", fullURL) + + req, err := http.NewRequest("GET", fullURL, nil) + assert.NoError(t, err) + + // Add authorization header + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + t.Logf("Response status: %d", resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + // Verify basic OIDC fields + assert.NotEmpty(t, result["sub"], "sub field should be present") + assert.NotEmpty(t, result["yao:user_id"], "yao:user_id field should be present") + + // Team, member, and type should NOT be present in basic profile + assert.Nil(t, result["yao:team"], "team should not be present without team=true") + assert.Nil(t, result["member"], "member should not be present without member=true") + assert.Nil(t, result["yao:type"], "type should not be present without type=true") + + t.Logf("Basic profile retrieved successfully for user: %s", result["yao:user_id"]) + }) + + // Test 2: Get profile with team parameter (but no team context in token) + t.Run("GetProfileWithTeamParameter", func(t *testing.T) { + // Request profile with team=true but without team context + // This should return profile without team info (since no team context) + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile?team=true", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d", resp.StatusCode) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.Unmarshal(bodyBytes, &result) + assert.NoError(t, err) + + // Without team context in token, team info should not be present + assert.Nil(t, result["yao:team"], "team info should not be present without team context") + assert.Empty(t, result["yao:team_id"], "team_id should not be present without team context") + + t.Logf("Profile request with team parameter (but no team context) handled correctly") + }) + + // Test 3: Get profile with member parameter (but no team context) + t.Run("GetProfileWithMemberParameter", func(t *testing.T) { + // Request profile with member=true but without team context + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile?member=true", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + // Without team context, member info should not be present + assert.Nil(t, result["member"], "member info should not be present without team context") + + t.Logf("Profile request with member parameter (but no team context) handled correctly") + }) + + // Test 4: Get profile with type information + t.Run("GetProfileWithType", func(t *testing.T) { + // Create a user type + provider := testutils.GetUserProvider(t) + ctx := context.Background() + + typeData := map[string]interface{}{ + "type_id": fmt.Sprintf("test_type_%d", time.Now().UnixNano()), + "name": "Test User Type", + "locale": "en", + "description": "Type for profile testing", + "is_active": true, + "created_at": time.Now(), + "updated_at": time.Now(), + } + + typeID, err := provider.CreateType(ctx, typeData) + assert.NoError(t, err) + t.Logf("Created test type: %s", typeID) + + // Update user with type_id + err = provider.UpdateUser(ctx, tokenInfo.UserID, map[string]interface{}{ + "type_id": typeID, + }) + assert.NoError(t, err) + t.Logf("Updated user with type: %s", typeID) + + // Get profile with type=true + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile?type=true", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + // Verify type fields are present + assert.NotEmpty(t, result["yao:type_id"], "type_id should be present") + assert.NotNil(t, result["yao:type"], "type info should be present") + + if typeInfo, ok := result["yao:type"].(map[string]interface{}); ok { + assert.Equal(t, typeID, typeInfo["type_id"]) + assert.Equal(t, "Test User Type", typeInfo["name"]) + assert.Equal(t, "en", typeInfo["locale"]) + } + + t.Logf("Profile with type info retrieved successfully") + + // Cleanup + err = provider.DeleteType(ctx, typeID) + assert.NoError(t, err) + }) + + // Test 5: Get profile with all optional parameters (without team context) + t.Run("GetProfileWithAllOptions", func(t *testing.T) { + // Create type and update user + provider := testutils.GetUserProvider(t) + ctx := context.Background() + + // Create type + typeData := map[string]interface{}{ + "type_id": fmt.Sprintf("test_type_all_%d", time.Now().UnixNano()), + "name": "Complete Test Type", + "locale": "en-US", + "description": "Type for complete testing", + "is_active": true, + "created_at": time.Now(), + "updated_at": time.Now(), + } + + typeID, err := provider.CreateType(ctx, typeData) + assert.NoError(t, err) + + // Update user with type + err = provider.UpdateUser(ctx, tokenInfo.UserID, map[string]interface{}{ + "type_id": typeID, + }) + assert.NoError(t, err) + + // Get profile with all options (but without team context) + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile?team=true&member=true&type=true", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + // Verify user and type fields are present + assert.NotEmpty(t, result["yao:user_id"], "user_id should be present") + assert.NotEmpty(t, result["yao:type_id"], "type_id should be present") + assert.NotNil(t, result["yao:type"], "type info should be present") + + // Without team context, team and member should not be present + assert.Nil(t, result["yao:team"], "team info should not be present without team context") + assert.Nil(t, result["member"], "member info should not be present without team context") + + t.Logf("Profile with type info retrieved successfully (without team context)") + + // Cleanup + err = provider.DeleteType(ctx, typeID) + assert.NoError(t, err) + }) + + // Test 6: Get profile with REAL team context (create team, member, and properly signed token) + t.Run("GetProfileWithRealTeamContext", func(t *testing.T) { + provider := testutils.GetUserProvider(t) + ctx := context.Background() + + // Step 1: Create a team type first + teamTypeData := map[string]interface{}{ + "type_id": fmt.Sprintf("team_type_%d", time.Now().UnixNano()), + "name": "Pro Team Type", + "locale": "en-US", + "description": "Professional team type", + "is_active": true, + "created_at": time.Now(), + "updated_at": time.Now(), + } + teamTypeID, err := provider.CreateType(ctx, teamTypeData) + assert.NoError(t, err) + t.Logf("Created team type: %s", teamTypeID) + + // Step 2: Create a team with role_id (required for ACL) + teamData := map[string]interface{}{ + "team_id": fmt.Sprintf("test_team_real_%d", time.Now().UnixNano()), + "name": "Real Test Team", + "description": "Team with proper context", + "logo": "https://example.com/logo.png", + "owner_id": tokenInfo.UserID, + "type_id": teamTypeID, + "role_id": "system:root", // Required for ACL verification + "status": "active", + "is_verified": true, + "created_at": time.Now(), + "updated_at": time.Now(), + } + + teamID, err := provider.CreateTeam(ctx, teamData) + assert.NoError(t, err) + assert.NotEmpty(t, teamID) + t.Logf("Created test team: %s", teamID) + + // Step 3: Add user as team member with system:root role (to bypass ACL) + memberData := map[string]interface{}{ + "team_id": teamID, + "user_id": tokenInfo.UserID, + "member_type": "user", + "role_id": "system:root", // Use system:root for testing to bypass ACL + "is_owner": true, + "status": "active", + "joined_at": time.Now(), + "created_at": time.Now(), + "updated_at": time.Now(), + } + + memberID, err := provider.CreateMember(ctx, memberData) + assert.NoError(t, err) + assert.NotEmpty(t, memberID) + t.Logf("Added user as team member with system:root role: %s", memberID) + + // Step 4: Get team details for token creation + team, err := provider.GetTeamByMember(ctx, teamID, tokenInfo.UserID) + assert.NoError(t, err) + assert.NotNil(t, team) + + // Step 5: Create a properly signed token with team context (like issueTokens does) + oauthService := oauth.OAuth + assert.NotNil(t, oauthService, "OAuth service should be initialized") + + // Get or create subject + subject, err := oauthService.Subject(client.ClientID, tokenInfo.UserID) + assert.NoError(t, err) + + // Prepare extra claims with team context (matching login.go issueTokens) + extraClaims := map[string]interface{}{ + "user_id": tokenInfo.UserID, // Add user_id to claims + "team_id": teamID, + } + + // Add tenant_id if available from team + if tenantID, ok := team["tenant_id"].(string); ok && tenantID != "" { + extraClaims["tenant_id"] = tenantID + } + + // Add owner_id + if ownerID, ok := team["owner_id"].(string); ok && ownerID != "" { + extraClaims["owner_id"] = ownerID + } + + // Add type_id from team + if typeID, ok := team["type_id"].(string); ok && typeID != "" { + extraClaims["type_id"] = typeID + } + + // Create access token with team context + accessToken, err := oauthService.MakeAccessToken( + client.ClientID, + "openid profile email system:root", + subject, + 3600, + extraClaims, + ) + assert.NoError(t, err) + assert.NotEmpty(t, accessToken) + t.Logf("Created token with team context: team_id=%s", teamID) + + // Step 6: Request profile with team=true, member=true, type=true + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile?team=true&member=true&type=true", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(bodyBytes)) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Response body: %s", string(bodyBytes)) + + var result map[string]interface{} + err = json.Unmarshal(bodyBytes, &result) + assert.NoError(t, err) + + // Step 7: Verify all fields are present + assert.NotEmpty(t, result["yao:user_id"], "user_id should be present") + assert.NotEmpty(t, result["yao:team_id"], "team_id should be present") + assert.Equal(t, teamID, result["yao:team_id"], "team_id should match") + + // Verify team info + assert.NotNil(t, result["yao:team"], "team info should be present") + if teamInfo, ok := result["yao:team"].(map[string]interface{}); ok { + assert.Equal(t, teamID, teamInfo["team_id"], "team.team_id should match") + assert.Equal(t, "Real Test Team", teamInfo["name"], "team.name should match") + assert.Equal(t, "Team with proper context", teamInfo["description"], "team.description should match") + } + + // Verify member info + assert.NotNil(t, result["member"], "member info should be present") + if member, ok := result["member"].(map[string]interface{}); ok { + assert.Equal(t, teamID, member["team_id"], "member.team_id should match") + assert.Equal(t, tokenInfo.UserID, member["user_id"], "member.user_id should match") + assert.Equal(t, "system:root", member["role_id"], "member.role_id should match") + assert.Equal(t, "active", member["status"], "member.status should match") + } + + // Verify type info (should use team's type) + assert.NotEmpty(t, result["yao:type_id"], "type_id should be present") + assert.Equal(t, teamTypeID, result["yao:type_id"], "type_id should match team type") + assert.NotNil(t, result["yao:type"], "type info should be present") + if typeInfo, ok := result["yao:type"].(map[string]interface{}); ok { + assert.Equal(t, teamTypeID, typeInfo["type_id"], "type.type_id should match") + assert.Equal(t, "Pro Team Type", typeInfo["name"], "type.name should match") + } + + // Verify is_owner flag + if isOwner, ok := result["yao:is_owner"].(bool); ok { + assert.True(t, isOwner, "user should be team owner") + } + + t.Logf("✅ Profile with REAL team context retrieved successfully") + t.Logf(" - Team: %s", result["yao:team_id"]) + t.Logf(" - Member role: %s", result["member"].(map[string]interface{})["role_id"]) + t.Logf(" - Type: %s", result["yao:type_id"]) + + // Cleanup + err = provider.DeleteTeam(ctx, teamID) + assert.NoError(t, err) + err = provider.DeleteType(ctx, teamTypeID) + assert.NoError(t, err) + }) + + // Test 6: Unauthorized access + t.Run("UnauthorizedAccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile", nil) + assert.NoError(t, err) + + // No authorization header + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + assert.NotEmpty(t, result["error"], "error field should be present") + t.Logf("Unauthorized access correctly rejected") + }) + + // Test 7: Invalid token + t.Run("InvalidToken", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile", nil) + assert.NoError(t, err) + + // Invalid token + req.Header.Set("Authorization", "Bearer invalid_token_12345") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + + assert.NotEmpty(t, result["error"], "error field should be present") + t.Logf("Invalid token correctly rejected") + }) +} + +// TestProfileUpdate tests the PUT /user/profile endpoint +func TestProfileUpdate(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client + client := testutils.RegisterTestClient(t, "Profile Update Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + + // Create a test user with root permissions + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile email") + + // Test 1: Update basic profile fields + t.Run("UpdateBasicProfile", func(t *testing.T) { + updateData := map[string]interface{}{ + "name": "Updated Name", + "given_name": "Updated", + "family_name": "Name", + "nickname": "UpdatedNick", + "gender": "male", + "birthdate": "1990-05-15", + "locale": "zh-CN", + "zoneinfo": "Asia/Shanghai", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(bodyBytes)) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.Unmarshal(bodyBytes, &result) + assert.NoError(t, err) + + // Verify response contains user_id and message + assert.Equal(t, tokenInfo.UserID, result["user_id"], "user_id should match") + assert.Equal(t, "Profile updated successfully", result["message"], "message should be present") + + t.Logf("✅ Profile updated successfully") + + // Verify the update by getting the profile + getReq, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile", nil) + assert.NoError(t, err) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + getResp, err := http.DefaultClient.Do(getReq) + assert.NoError(t, err) + defer getResp.Body.Close() + + var profile map[string]interface{} + err = json.NewDecoder(getResp.Body).Decode(&profile) + assert.NoError(t, err) + + // Verify updated fields + assert.Equal(t, "Updated Name", profile["name"]) + assert.Equal(t, "Updated", profile["given_name"]) + assert.Equal(t, "Name", profile["family_name"]) + assert.Equal(t, "UpdatedNick", profile["nickname"]) + assert.Equal(t, "male", profile["gender"]) + assert.Equal(t, "1990-05-15", profile["birthdate"]) + assert.Equal(t, "zh-CN", profile["locale"]) + assert.Equal(t, "Asia/Shanghai", profile["zoneinfo"]) + + t.Logf("✅ Profile fields verified after update") + }) + + // Test 2: Update profile with picture and website + t.Run("UpdateProfileWithLinks", func(t *testing.T) { + updateData := map[string]interface{}{ + "picture": "https://example.com/avatar-new.jpg", + "website": "https://mynewsite.com", + "profile": "https://mynewsite.com/profile", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.Equal(t, tokenInfo.UserID, result["user_id"]) + assert.Equal(t, "Profile updated successfully", result["message"]) + + t.Logf("✅ Profile links updated successfully") + }) + + // Test 3: Update with address and metadata + t.Run("UpdateWithAddressAndMetadata", func(t *testing.T) { + updateData := map[string]interface{}{ + "address": map[string]interface{}{ + "formatted": "北京市朝阳区xxx街道", + "street_address": "xxx街道123号", + "locality": "北京", + "region": "北京市", + "postal_code": "100000", + "country": "中国", + }, + "metadata": map[string]interface{}{ + "bio": "全栈开发工程师", + "company": "示例科技公司", + "skills": []string{"Go", "React", "TypeScript"}, + }, + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.Equal(t, tokenInfo.UserID, result["user_id"]) + assert.Equal(t, "Profile updated successfully", result["message"]) + + t.Logf("✅ Address and metadata updated successfully") + + // Verify the update + getReq, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile", nil) + assert.NoError(t, err) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + getResp, err := http.DefaultClient.Do(getReq) + assert.NoError(t, err) + defer getResp.Body.Close() + + var profile map[string]interface{} + err = json.NewDecoder(getResp.Body).Decode(&profile) + assert.NoError(t, err) + + // Verify address + if address, ok := profile["address"].(map[string]interface{}); ok { + assert.Equal(t, "北京市朝阳区xxx街道", address["formatted"]) + assert.Equal(t, "中国", address["country"]) + } + + // Verify metadata + if metadata, ok := profile["yao:metadata"].(map[string]interface{}); ok { + assert.Equal(t, "全栈开发工程师", metadata["bio"]) + assert.Equal(t, "示例科技公司", metadata["company"]) + } + + t.Logf("✅ Address and metadata verified") + }) + + // Test 4: Update theme preference + t.Run("UpdateTheme", func(t *testing.T) { + updateData := map[string]interface{}{ + "theme": "dark", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.Equal(t, tokenInfo.UserID, result["user_id"]) + assert.Equal(t, "Profile updated successfully", result["message"]) + + t.Logf("✅ Theme preference updated successfully") + }) + + // Test 5: Empty update should fail + t.Run("EmptyUpdateShouldFail", func(t *testing.T) { + updateData := map[string]interface{}{} + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.NotEmpty(t, result["error"], "error should be present for empty update") + + t.Logf("✅ Empty update correctly rejected") + }) + + // Test 6: Invalid JSON should fail + t.Run("InvalidJSONShouldFail", func(t *testing.T) { + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader("{invalid json}")) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.NotEmpty(t, result["error"], "error should be present for invalid JSON") + + t.Logf("✅ Invalid JSON correctly rejected") + }) + + // Test 7: Unauthorized access + t.Run("UnauthorizedUpdate", func(t *testing.T) { + updateData := map[string]interface{}{ + "name": "Unauthorized Update", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + // No authorization header + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.NotEmpty(t, result["error"], "error should be present") + + t.Logf("✅ Unauthorized update correctly rejected") + }) + + // Test 8: Invalid token + t.Run("InvalidTokenUpdate", func(t *testing.T) { + updateData := map[string]interface{}{ + "name": "Invalid Token Update", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer invalid_token_xyz") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.NotEmpty(t, result["error"], "error should be present") + + t.Logf("✅ Invalid token update correctly rejected") + }) + + // Test 9: Partial update (only one field) + t.Run("PartialUpdate", func(t *testing.T) { + updateData := map[string]interface{}{ + "nickname": "PartialNick", + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req, err := http.NewRequest("PUT", serverURL+baseURL+"/user/profile", strings.NewReader(string(body))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + assert.NoError(t, err) + assert.Equal(t, tokenInfo.UserID, result["user_id"]) + assert.Equal(t, "Profile updated successfully", result["message"]) + + t.Logf("✅ Partial update (single field) successful") + + // Verify only nickname was updated + getReq, err := http.NewRequest("GET", serverURL+baseURL+"/user/profile", nil) + assert.NoError(t, err) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + getResp, err := http.DefaultClient.Do(getReq) + assert.NoError(t, err) + defer getResp.Body.Close() + + var profile map[string]interface{} + err = json.NewDecoder(getResp.Body).Decode(&profile) + assert.NoError(t, err) + + assert.Equal(t, "PartialNick", profile["nickname"]) + + t.Logf("✅ Partial update verified") + }) +} diff --git a/openapi/user/member.go b/openapi/user/member.go index da70c96b..ebab442d 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -575,6 +575,82 @@ func GinMemberUpdate(c *gin.Context) { response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Member updated successfully"}) } +// GinMemberUpdateProfile handles PUT /teams/:team_id/members/:member_id/profile - Update member profile +// Note: :member_id in the route actually contains user_id for profile updates +func GinMemberUpdateProfile(c *gin.Context) { + // Get authorized user info + authInfo := authorized.GetInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("id") + // For profile updates, :member_id parameter contains the user_id (not member_id) + memberUserID := c.Param("member_id") + if teamID == "" || memberUserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID and User ID are required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req UpdateMemberProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Call business logic + err := memberUpdateProfile(c.Request.Context(), authInfo.UserID, teamID, memberUserID, req) + if err != nil { + log.Error("Failed to update member profile: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Member not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else if strings.Contains(err.Error(), "no fields to update") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update member profile", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "user_id": memberUserID, + "message": "Member profile updated successfully", + }) +} + // GinMemberDelete handles DELETE /teams/:team_id/members/:member_id - Remove team member func GinMemberDelete(c *gin.Context) { // Get authorized user info @@ -801,6 +877,58 @@ func ProcessMemberUpdate(process *process.Process) interface{} { } } +// ProcessMemberUpdateProfile user.member.profile.update Member profile update processor +// Args[0] string: team_id +// Args[1] string: user_id (member's user_id) +// Args[2] map: Update profile data {"display_name": "John Doe", "bio": "Developer", "avatar": "https://...", "email": "john@example.com"} +// Return: map: {"user_id": "xxx", "message": "success"} +func ProcessMemberUpdateProfile(process *process.Process) interface{} { + process.ValidateArgNums(3) + + // Get user_id from session + requestUserID := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + memberUserID := process.ArgsString(1) + profileData := process.ArgsMap(2) + + if teamID == "" || memberUserID == "" { + exception.New("team_id and user_id are required", 400).Throw() + } + + // Build UpdateMemberProfileRequest from map + req := UpdateMemberProfileRequest{} + if displayName, ok := profileData["display_name"].(string); ok && displayName != "" { + req.DisplayName = &displayName + } + if bio, ok := profileData["bio"].(string); ok && bio != "" { + req.Bio = &bio + } + if avatar, ok := profileData["avatar"].(string); ok && avatar != "" { + req.Avatar = &avatar + } + if email, ok := profileData["email"].(string); ok && email != "" { + req.Email = &email + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + err := memberUpdateProfile(ctx, requestUserID, teamID, memberUserID, req) + if err != nil { + exception.New("failed to update member profile: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "user_id": memberUserID, + "message": "success", + } +} + // ProcessMemberDelete user.member.delete Member delete processor // Args[0] string: team_id // Args[1] string: member_id @@ -1128,6 +1256,64 @@ func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateDa return nil } +// memberUpdateProfile handles the business logic for updating member profile information +func memberUpdateProfile(ctx context.Context, requestUserID, teamID, memberUserID string, req UpdateMemberProfileRequest) error { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if member exists using team_id and user_id + member, err := provider.GetMember(ctx, teamID, memberUserID) + if err != nil { + return fmt.Errorf("member not found: %w", err) + } + + // Verify member exists + if member == nil { + return fmt.Errorf("member not found in the specified team") + } + + // Check if the requesting user is the member themselves + // Only members can update their own profile + if memberUserID != requestUserID { + return fmt.Errorf("access denied: you can only update your own profile") + } + + // Build update data map (only include non-nil fields) + updateData := make(map[string]interface{}) + + if req.DisplayName != nil { + updateData["display_name"] = *req.DisplayName + } + if req.Bio != nil { + updateData["bio"] = *req.Bio + } + if req.Avatar != nil { + updateData["avatar"] = *req.Avatar + } + if req.Email != nil { + updateData["email"] = *req.Email + } + + // If no fields to update, return error + if len(updateData) == 0 { + return fmt.Errorf("no fields to update") + } + + // Add updated_at timestamp + updateData["updated_at"] = time.Now() + + // Update member using team_id and user_id + err = provider.UpdateMember(ctx, teamID, memberUserID, updateData) + if err != nil { + return fmt.Errorf("failed to update member profile: %w", err) + } + + return nil +} + // memberDelete handles the business logic for deleting a team member func memberDelete(ctx context.Context, userID, teamID, memberID string) error { // Check if user has access to the team (write permission: owner only) diff --git a/openapi/user/profile.go b/openapi/user/profile.go index d5317f57..70e971e8 100644 --- a/openapi/user/profile.go +++ b/openapi/user/profile.go @@ -2,11 +2,18 @@ package user import ( "context" + "fmt" "net/http" "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/process" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" + "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/providers/user" oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" ) @@ -16,7 +23,7 @@ import ( // GinProfileGet handles GET /profile - Get current user profile func GinProfileGet(c *gin.Context) { // Get authorized user info - authInfo := oauth.GetAuthorizedInfo(c) + authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, @@ -26,25 +33,15 @@ func GinProfileGet(c *gin.Context) { return } - // Get user provider - userProvider, err := oauth.OAuth.GetUserProvider() - if err != nil { - log.Error("Failed to get user provider: %v", err) - errorResp := &response.ErrorResponse{ - Code: response.ErrServerError.Code, - ErrorDescription: "Failed to get user provider", - } - response.RespondWithError(c, response.StatusInternalServerError, errorResp) - return + // Parse query parameters + var req ProfileGetRequest + if err := c.ShouldBindQuery(&req); err != nil { + // Ignore binding errors for optional parameters + req = ProfileGetRequest{} } - // Get user data with scopes - ctx := c.Request.Context() - if ctx == nil { - ctx = context.Background() - } - - user, err := userProvider.GetUserWithScopes(ctx, authInfo.UserID) + // Call business logic to get profile + profile, err := profileGet(c.Request.Context(), authInfo.UserID, authInfo.TeamID, req) if err != nil { log.Error("Failed to get user profile: %v", err) errorResp := &response.ErrorResponse{ @@ -55,98 +52,380 @@ func GinProfileGet(c *gin.Context) { return } + // Return user profile + response.RespondWithSuccess(c, http.StatusOK, profile) +} + +// GinProfileUpdate handles PUT /profile - Update current user profile +func GinProfileUpdate(c *gin.Context) { + // Get authorized user info + authInfo := authorized.GetInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + // Parse request body + var req ProfileUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Invalid request body: %s", err.Error()), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Call business logic to update profile + result, err := profileUpdate(c.Request.Context(), authInfo.UserID, req) + if err != nil { + log.Error("Failed to update user profile: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update user profile", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Return response with user_id and message + response.RespondWithSuccess(c, http.StatusOK, result) +} + +// Yao Process Handlers (for Yao application calls) + +// ProcessProfileGet user.profile.get Profile get processor +// Args[0] (optional) map: {"team": true, "member": true, "type": true} +// Return: map: User profile data +func ProcessProfileGet(process *process.Process) interface{} { + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Get team_id from session if available + teamID := "" + if teamIDVal, err := session.Global().ID(process.Sid).Get("__team_id"); err == nil && teamIDVal != nil { + if teamIDStr, ok := teamIDVal.(string); ok { + teamID = teamIDStr + } + } + + // Parse options + req := ProfileGetRequest{} + if process.NumOfArgs() > 0 { + opts := process.ArgsMap(0) + if v, ok := opts["team"].(bool); ok { + req.Team = v + } + if v, ok := opts["member"].(bool); ok { + req.Member = v + } + if v, ok := opts["type"].(bool); ok { + req.Type = v + } + } + + // Call business logic + result, err := profileGet(ctx, userIDStr, teamID, req) + if err != nil { + exception.New("failed to get profile: %s", 500, err.Error()).Throw() + } + + return result +} + +// ProcessProfileUpdate user.profile.update Profile update processor +// Args[0] map: Profile update data (only profile fields allowed) +// Return: map: Updated user profile data +func ProcessProfileUpdate(process *process.Process) interface{} { + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Parse update data from first argument + if process.NumOfArgs() == 0 { + exception.New("profile update data is required", 400).Throw() + } + + updateData := process.ArgsMap(0) + req := buildProfileUpdateRequest(updateData) + + // Call business logic + result, err := profileUpdate(ctx, userIDStr, req) + if err != nil { + exception.New("failed to update profile: %s", 500, err.Error()).Throw() + } + + return result +} + +// Private Business Logic Functions (internal use only) + +// profileGet handles the business logic for getting user profile +func profileGet(ctx context.Context, userID, teamID string, req ProfileGetRequest) (maps.MapStrAny, error) { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Get user data + userData, err := provider.GetUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve user data: %w", err) + } + // Get Yao client config yaoClientConfig := GetYaoClientConfig() // Get or create subject - subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, authInfo.UserID) + subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userID) if err != nil { log.Warn("Failed to get user subject: %s", err.Error()) - subject = authInfo.UserID // Fallback to user ID + subject = userID // Fallback to user ID } // Prepare OIDC user info (same format as login response) - oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) + oidcUserInfo := oauthtypes.MakeOIDCUserInfo(userData) oidcUserInfo.Sub = subject - oidcUserInfo.YaoUserID = authInfo.UserID + oidcUserInfo.YaoUserID = userID - // Add team context if available from token - if authInfo.TeamID != "" { - // Get team details - team, err := userProvider.GetTeamByMember(ctx, authInfo.TeamID, authInfo.UserID) - if err == nil && team != nil { - // Add team info to OIDC user info - oidcUserInfo.YaoTeamID = authInfo.TeamID + // Add team and member info if requested and team_id is provided + if teamID != "" && (req.Team || req.Member) { + addTeamInfo(ctx, provider, oidcUserInfo, teamID, userID, req.Team, req.Member) + } - teamInfo := &oauthtypes.OIDCTeamInfo{} - if teamIDVal := toString(team["team_id"]); teamIDVal != "" { - teamInfo.TeamID = teamIDVal - } - if logo := toString(team["logo"]); logo != "" { - teamInfo.Logo = logo - } - if name := toString(team["name"]); name != "" { - teamInfo.Name = name - } - if description := toString(team["description"]); description != "" { - teamInfo.Description = description - } - if ownerID := toString(team["owner_id"]); ownerID != "" { - teamInfo.OwnerID = ownerID + // Add type information if requested + if req.Type { + addTypeInfo(ctx, provider, oidcUserInfo, userData, teamID, userID) + } - // Check if user is owner - if ownerID == authInfo.UserID { - isOwner := true - oidcUserInfo.YaoIsOwner = &isOwner - } - } - oidcUserInfo.YaoTeam = teamInfo + // Convert to map for response (this will include all Yao fields) + profileData := oidcUserInfo.Map() - // Add tenant_id if available from the team - if tenantID := toString(team["tenant_id"]); tenantID != "" { - oidcUserInfo.YaoTenantID = tenantID - } + // Add member as separate object if requested (not part of OIDCUserInfo structure) + if req.Member && teamID != "" { + member, err := provider.GetMember(ctx, teamID, userID) + if err == nil && member != nil { + profileData["member"] = member } } - // Add type information + return profileData, nil +} + +// addTeamInfo adds team information to the profile +func addTeamInfo(ctx context.Context, provider *user.DefaultUser, oidcUserInfo *oauthtypes.OIDCUserInfo, teamID, userID string, withTeam, withMember bool) { + // Only fetch team if either team or member info is requested + if !withTeam && !withMember { + return + } + + // Get team details + team, err := provider.GetTeamByMember(ctx, teamID, userID) + if err != nil { + log.Warn("Failed to get team: %v", err) + return + } + + // Add team info to OIDCUserInfo if requested + if withTeam { + oidcUserInfo.YaoTeamID = teamID + oidcUserInfo.YaoTeam = &oauthtypes.OIDCTeamInfo{ + TeamID: toString(team["team_id"]), + Name: toString(team["name"]), + Description: toString(team["description"]), + Logo: toString(team["logo"]), + OwnerID: toString(team["owner_id"]), + } + + // Check if user is owner + if oidcUserInfo.YaoTeam.OwnerID == userID { + isOwner := true + oidcUserInfo.YaoIsOwner = &isOwner + } + + // Add tenant_id if available + if tenantID := toString(team["tenant_id"]); tenantID != "" { + oidcUserInfo.YaoTenantID = tenantID + } + } +} + +// addTypeInfo adds type information to the profile +func addTypeInfo(ctx context.Context, provider *user.DefaultUser, oidcUserInfo *oauthtypes.OIDCUserInfo, userData maps.MapStr, teamID, userID string) { var typeID string - if authInfo.TeamID != "" { - // Team context - try to get team's type first - team, err := userProvider.GetTeamByMember(ctx, authInfo.TeamID, authInfo.UserID) + + // Team context - try to get team's type first + if teamID != "" { + team, err := provider.GetTeamByMember(ctx, teamID, userID) if err == nil && team != nil { typeID = toString(team["type_id"]) } } - // Fallback to user's type if no team type + // Fallback to user's type if typeID == "" { - typeID = toString(user["type_id"]) + typeID = toString(userData["type_id"]) } - if typeID != "" { - oidcUserInfo.YaoTypeID = typeID - - // Get type details - typeInfo, err := userProvider.GetType(ctx, typeID) - if err == nil && typeInfo != nil { - typeDetails := &oauthtypes.OIDCTypeInfo{} - if typeIDVal := toString(typeInfo["type_id"]); typeIDVal != "" { - typeDetails.TypeID = typeIDVal - } - if name := toString(typeInfo["name"]); name != "" { - typeDetails.Name = name - } - if locale := toString(typeInfo["locale"]); locale != "" { - typeDetails.Locale = locale - } - oidcUserInfo.YaoType = typeDetails - } + if typeID == "" { + return } - // Convert to map for response - profileData := oidcUserInfo.Map() + oidcUserInfo.YaoTypeID = typeID - // Return user profile - response.RespondWithSuccess(c, http.StatusOK, profileData) + // Get type details + typeInfo, err := provider.GetType(ctx, typeID) + if err != nil { + log.Warn("Failed to get type: %v", err) + return + } + + oidcUserInfo.YaoType = &oauthtypes.OIDCTypeInfo{ + TypeID: toString(typeInfo["type_id"]), + Name: toString(typeInfo["name"]), + Locale: toString(typeInfo["locale"]), + } +} + +// profileUpdate handles the business logic for updating user profile +func profileUpdate(ctx context.Context, userID string, req ProfileUpdateRequest) (ProfileUpdateResponse, error) { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return ProfileUpdateResponse{}, fmt.Errorf("failed to get user provider: %w", err) + } + + // Build update data map (only include non-nil fields) + updateData := make(map[string]interface{}) + + if req.Name != nil { + updateData["name"] = *req.Name + } + if req.GivenName != nil { + updateData["given_name"] = *req.GivenName + } + if req.FamilyName != nil { + updateData["family_name"] = *req.FamilyName + } + if req.MiddleName != nil { + updateData["middle_name"] = *req.MiddleName + } + if req.Nickname != nil { + updateData["nickname"] = *req.Nickname + } + if req.Profile != nil { + updateData["profile"] = *req.Profile + } + if req.Picture != nil { + updateData["picture"] = *req.Picture + } + if req.Website != nil { + updateData["website"] = *req.Website + } + if req.Gender != nil { + updateData["gender"] = *req.Gender + } + if req.Birthdate != nil { + updateData["birthdate"] = *req.Birthdate + } + if req.Zoneinfo != nil { + updateData["zoneinfo"] = *req.Zoneinfo + } + if req.Locale != nil { + updateData["locale"] = *req.Locale + } + if req.Address != nil { + updateData["address"] = req.Address + } + if req.Theme != nil { + updateData["theme"] = *req.Theme + } + if req.Metadata != nil { + updateData["metadata"] = req.Metadata + } + + // If no fields to update, return error + if len(updateData) == 0 { + return ProfileUpdateResponse{}, fmt.Errorf("no fields to update") + } + + // Update user profile + if err := provider.UpdateUser(ctx, userID, updateData); err != nil { + return ProfileUpdateResponse{}, fmt.Errorf("failed to update user profile: %w", err) + } + + // Return response with user_id and message + return ProfileUpdateResponse{ + UserID: userID, + Message: "Profile updated successfully", + }, nil +} + +// buildProfileUpdateRequest converts a map to ProfileUpdateRequest +func buildProfileUpdateRequest(data map[string]interface{}) ProfileUpdateRequest { + req := ProfileUpdateRequest{} + + if v, ok := data["name"].(string); ok { + req.Name = &v + } + if v, ok := data["given_name"].(string); ok { + req.GivenName = &v + } + if v, ok := data["family_name"].(string); ok { + req.FamilyName = &v + } + if v, ok := data["middle_name"].(string); ok { + req.MiddleName = &v + } + if v, ok := data["nickname"].(string); ok { + req.Nickname = &v + } + if v, ok := data["profile"].(string); ok { + req.Profile = &v + } + if v, ok := data["picture"].(string); ok { + req.Picture = &v + } + if v, ok := data["website"].(string); ok { + req.Website = &v + } + if v, ok := data["gender"].(string); ok { + req.Gender = &v + } + if v, ok := data["birthdate"].(string); ok { + req.Birthdate = &v + } + if v, ok := data["zoneinfo"].(string); ok { + req.Zoneinfo = &v + } + if v, ok := data["locale"].(string); ok { + req.Locale = &v + } + if v, ok := data["address"].(map[string]interface{}); ok { + req.Address = v + } + if v, ok := data["theme"].(string); ok { + req.Theme = &v + } + if v, ok := data["metadata"].(map[string]interface{}); ok { + req.Metadata = v + } + + return req } diff --git a/openapi/user/types.go b/openapi/user/types.go index 46760eec..f53f01ca 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -513,6 +513,52 @@ type UpdateMemberRequest struct { LastActivity string `json:"last_activity,omitempty"` } +// UpdateMemberProfileRequest represents the request to update member profile information +// Only allows updating profile-related fields (display_name, bio, avatar, email) +type UpdateMemberProfileRequest struct { + DisplayName *string `json:"display_name,omitempty"` // Member display name + Bio *string `json:"bio,omitempty"` // Member bio/description + Avatar *string `json:"avatar,omitempty"` // Avatar URL or file ID + Email *string `json:"email,omitempty"` // Email address (for display only) +} + +// ==== Profile API Types ==== + +// ProfileGetRequest represents the request to get user profile with optional expansions +type ProfileGetRequest struct { + Team bool `json:"team" form:"team"` // Include team information + Member bool `json:"member" form:"member"` // Include member information + Type bool `json:"type" form:"type"` // Include type information +} + +// ProfileUpdateResponse represents the response after updating user profile +type ProfileUpdateResponse struct { + UserID string `json:"user_id"` + Message string `json:"message"` +} + +// ProfileUpdateRequest represents the request to update user profile +// Only allows updating profile-related fields (OIDC standard claims and preferences) +// Note: preferred_username, email, and phone_number cannot be updated through this endpoint (use dedicated account management endpoints) +type ProfileUpdateRequest struct { + // OIDC Standard Claims - Profile Information + Name *string `json:"name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + MiddleName *string `json:"middle_name,omitempty"` + Nickname *string `json:"nickname,omitempty"` + Profile *string `json:"profile,omitempty"` // Profile page URL + Picture *string `json:"picture,omitempty"` // Profile picture URL + Website *string `json:"website,omitempty"` // Website URL + Gender *string `json:"gender,omitempty"` // Gender + Birthdate *string `json:"birthdate,omitempty"` // Birthdate (YYYY-MM-DD) + Zoneinfo *string `json:"zoneinfo,omitempty"` // Timezone + Locale *string `json:"locale,omitempty"` // Locale + Address map[string]any `json:"address,omitempty"` // Address (JSON object) + Theme *string `json:"theme,omitempty"` // UI theme preference + Metadata map[string]any `json:"metadata,omitempty"` // Extended metadata +} + // ==== Invitation API Types ==== // InvitationResponse represents a team invitation in API responses diff --git a/openapi/user/user.go b/openapi/user/user.go index 0f95d3c8..50144eab 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -57,11 +57,25 @@ func init() { // Register user process handlers process.RegisterGroup("user", map[string]process.Handler{ - "team.list": ProcessTeamList, - "team.get": ProcessTeamGet, - "team.create": ProcessTeamCreate, - "team.update": ProcessTeamUpdate, - "team.delete": ProcessTeamDelete, + // Profile + "profile.get": ProcessProfileGet, + "profile.update": ProcessProfileUpdate, + + // Team Management + "team.list": ProcessTeamList, + "team.get": ProcessTeamGet, + "team.create": ProcessTeamCreate, + "team.update": ProcessTeamUpdate, + "team.delete": ProcessTeamDelete, + + // Team Member Management + "member.list": ProcessMemberList, + "member.get": ProcessMemberGet, + "member.update": ProcessMemberUpdate, + "member.profile.update": ProcessMemberUpdateProfile, + "member.delete": ProcessMemberDelete, + + // Team Invitation Management "team.invitation.list": ProcessTeamInvitationList, "team.invitation.get": ProcessTeamInvitationGet, "team.invitation.create": ProcessTeamInvitationCreate, @@ -138,8 +152,9 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { team.GET("/:id/members/check-robot-email", GinMemberCheckRobotEmail) // GET /api/user/teams/:id/members/check-robot-email?robot_email=xxx - Check if robot email exists globally team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member team.PUT("/:id/members/robots/:member_id", GinMemberUpdateRobot) // PUT /api/user/teams/:id/members/robots/:member_id - Update robot member + team.PUT("/:id/members/:member_id/profile", GinMemberUpdateProfile) // PUT /api/user/teams/:id/members/:member_id/profile - Update member profile (display_name, bio, avatar, email) team.GET("/:id/members/:member_id", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details - team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member + team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member (admin: role, status) team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member // Team Invitations - Nested resource endpoints @@ -246,8 +261,8 @@ func attachProfile(group *gin.RouterGroup, oauth types.OAuth) { profile := group.Group("/profile") profile.Use(oauth.Guard) - profile.GET("/", GinProfileGet) // Get user profile - profile.PUT("/", placeholder) // Update user profile + profile.GET("/", GinProfileGet) // Get user profile + profile.PUT("/", GinProfileUpdate) // Update user profile } // User management (CRUD)