Implement member profile update functionality
- Added a new endpoint `PUT /teams/:team_id/members/:member_id/profile` to allow members to update their profile information, including display name, bio, avatar, and email. - Introduced `UpdateMemberProfileRequest` structure to handle profile update requests. - Enhanced the `GinMemberUpdateProfile` handler to process profile updates with appropriate validation and error handling. - Implemented business logic in `memberUpdateProfile` to ensure only the member can update their own profile. - Expanded test cases to validate the new profile update functionality, ensuring comprehensive coverage for various update scenarios.
This commit is contained in:
parent
b2c7ddde19
commit
af02a21f65
7 changed files with 1713 additions and 91 deletions
|
|
@ -98,6 +98,9 @@ func (user OIDCUserInfo) Map() map[string]interface{} {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Yao custom fields with namespace
|
// Add Yao custom fields with namespace
|
||||||
|
if user.YaoUserID != "" {
|
||||||
|
result["yao:user_id"] = user.YaoUserID
|
||||||
|
}
|
||||||
if user.YaoTenantID != "" {
|
if user.YaoTenantID != "" {
|
||||||
result["yao:tenant_id"] = user.YaoTenantID
|
result["yao:tenant_id"] = user.YaoTenantID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,7 @@ func createTestTeam(t *testing.T, serverURL, baseURL, accessToken, teamName stri
|
||||||
createTeamBody := map[string]interface{}{
|
createTeamBody := map[string]interface{}{
|
||||||
"name": teamName,
|
"name": teamName,
|
||||||
"description": "Team created for testing purposes",
|
"description": "Team created for testing purposes",
|
||||||
|
"role_id": "system:root", // Use system:root role which includes all scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(createTeamBody)
|
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
|
// Note: getTeamID function is already defined in team_test.go
|
||||||
|
|
|
||||||
811
openapi/tests/user/profile_test.go
Normal file
811
openapi/tests/user/profile_test.go
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -575,6 +575,82 @@ func GinMemberUpdate(c *gin.Context) {
|
||||||
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Member updated successfully"})
|
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
|
// GinMemberDelete handles DELETE /teams/:team_id/members/:member_id - Remove team member
|
||||||
func GinMemberDelete(c *gin.Context) {
|
func GinMemberDelete(c *gin.Context) {
|
||||||
// Get authorized user info
|
// 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
|
// ProcessMemberDelete user.member.delete Member delete processor
|
||||||
// Args[0] string: team_id
|
// Args[0] string: team_id
|
||||||
// Args[1] string: member_id
|
// Args[1] string: member_id
|
||||||
|
|
@ -1128,6 +1256,64 @@ func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateDa
|
||||||
return nil
|
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
|
// memberDelete handles the business logic for deleting a team member
|
||||||
func memberDelete(ctx context.Context, userID, teamID, memberID string) error {
|
func memberDelete(ctx context.Context, userID, teamID, memberID string) error {
|
||||||
// Check if user has access to the team (write permission: owner only)
|
// Check if user has access to the team (write permission: owner only)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/log"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"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"
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
@ -16,7 +23,7 @@ import (
|
||||||
// GinProfileGet handles GET /profile - Get current user profile
|
// GinProfileGet handles GET /profile - Get current user profile
|
||||||
func GinProfileGet(c *gin.Context) {
|
func GinProfileGet(c *gin.Context) {
|
||||||
// Get authorized user info
|
// Get authorized user info
|
||||||
authInfo := oauth.GetAuthorizedInfo(c)
|
authInfo := authorized.GetInfo(c)
|
||||||
if authInfo == nil || authInfo.UserID == "" {
|
if authInfo == nil || authInfo.UserID == "" {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidClient.Code,
|
Code: response.ErrInvalidClient.Code,
|
||||||
|
|
@ -26,25 +33,15 @@ func GinProfileGet(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user provider
|
// Parse query parameters
|
||||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
var req ProfileGetRequest
|
||||||
if err != nil {
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
log.Error("Failed to get user provider: %v", err)
|
// Ignore binding errors for optional parameters
|
||||||
errorResp := &response.ErrorResponse{
|
req = ProfileGetRequest{}
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to get user provider",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user data with scopes
|
// Call business logic to get profile
|
||||||
ctx := c.Request.Context()
|
profile, err := profileGet(c.Request.Context(), authInfo.UserID, authInfo.TeamID, req)
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := userProvider.GetUserWithScopes(ctx, authInfo.UserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to get user profile: %v", err)
|
log.Error("Failed to get user profile: %v", err)
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
|
|
@ -55,98 +52,380 @@ func GinProfileGet(c *gin.Context) {
|
||||||
return
|
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
|
// Get Yao client config
|
||||||
yaoClientConfig := GetYaoClientConfig()
|
yaoClientConfig := GetYaoClientConfig()
|
||||||
|
|
||||||
// Get or create subject
|
// Get or create subject
|
||||||
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, authInfo.UserID)
|
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Failed to get user subject: %s", err.Error())
|
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)
|
// Prepare OIDC user info (same format as login response)
|
||||||
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(userData)
|
||||||
oidcUserInfo.Sub = subject
|
oidcUserInfo.Sub = subject
|
||||||
oidcUserInfo.YaoUserID = authInfo.UserID
|
oidcUserInfo.YaoUserID = userID
|
||||||
|
|
||||||
// Add team context if available from token
|
// Add team and member info if requested and team_id is provided
|
||||||
if authInfo.TeamID != "" {
|
if teamID != "" && (req.Team || req.Member) {
|
||||||
// Get team details
|
addTeamInfo(ctx, provider, oidcUserInfo, teamID, userID, req.Team, req.Member)
|
||||||
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
|
|
||||||
|
|
||||||
teamInfo := &oauthtypes.OIDCTeamInfo{}
|
// Add type information if requested
|
||||||
if teamIDVal := toString(team["team_id"]); teamIDVal != "" {
|
if req.Type {
|
||||||
teamInfo.TeamID = teamIDVal
|
addTypeInfo(ctx, provider, oidcUserInfo, userData, teamID, userID)
|
||||||
}
|
}
|
||||||
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
|
|
||||||
|
|
||||||
// Check if user is owner
|
// Convert to map for response (this will include all Yao fields)
|
||||||
if ownerID == authInfo.UserID {
|
profileData := oidcUserInfo.Map()
|
||||||
isOwner := true
|
|
||||||
oidcUserInfo.YaoIsOwner = &isOwner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
oidcUserInfo.YaoTeam = teamInfo
|
|
||||||
|
|
||||||
// Add tenant_id if available from the team
|
// Add member as separate object if requested (not part of OIDCUserInfo structure)
|
||||||
if tenantID := toString(team["tenant_id"]); tenantID != "" {
|
if req.Member && teamID != "" {
|
||||||
oidcUserInfo.YaoTenantID = tenantID
|
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
|
var typeID string
|
||||||
if authInfo.TeamID != "" {
|
|
||||||
// Team context - try to get team's type first
|
// Team context - try to get team's type first
|
||||||
team, err := userProvider.GetTeamByMember(ctx, authInfo.TeamID, authInfo.UserID)
|
if teamID != "" {
|
||||||
|
team, err := provider.GetTeamByMember(ctx, teamID, userID)
|
||||||
if err == nil && team != nil {
|
if err == nil && team != nil {
|
||||||
typeID = toString(team["type_id"])
|
typeID = toString(team["type_id"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to user's type if no team type
|
// Fallback to user's type
|
||||||
if typeID == "" {
|
if typeID == "" {
|
||||||
typeID = toString(user["type_id"])
|
typeID = toString(userData["type_id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if typeID != "" {
|
if typeID == "" {
|
||||||
oidcUserInfo.YaoTypeID = typeID
|
return
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to map for response
|
oidcUserInfo.YaoTypeID = typeID
|
||||||
profileData := oidcUserInfo.Map()
|
|
||||||
|
|
||||||
// Return user profile
|
// Get type details
|
||||||
response.RespondWithSuccess(c, http.StatusOK, profileData)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,52 @@ type UpdateMemberRequest struct {
|
||||||
LastActivity string `json:"last_activity,omitempty"`
|
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 ====
|
// ==== Invitation API Types ====
|
||||||
|
|
||||||
// InvitationResponse represents a team invitation in API responses
|
// InvitationResponse represents a team invitation in API responses
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,25 @@ func init() {
|
||||||
|
|
||||||
// Register user process handlers
|
// Register user process handlers
|
||||||
process.RegisterGroup("user", map[string]process.Handler{
|
process.RegisterGroup("user", map[string]process.Handler{
|
||||||
"team.list": ProcessTeamList,
|
// Profile
|
||||||
"team.get": ProcessTeamGet,
|
"profile.get": ProcessProfileGet,
|
||||||
"team.create": ProcessTeamCreate,
|
"profile.update": ProcessProfileUpdate,
|
||||||
"team.update": ProcessTeamUpdate,
|
|
||||||
"team.delete": ProcessTeamDelete,
|
// 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.list": ProcessTeamInvitationList,
|
||||||
"team.invitation.get": ProcessTeamInvitationGet,
|
"team.invitation.get": ProcessTeamInvitationGet,
|
||||||
"team.invitation.create": ProcessTeamInvitationCreate,
|
"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.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.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/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.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.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member
|
||||||
|
|
||||||
// Team Invitations - Nested resource endpoints
|
// Team Invitations - Nested resource endpoints
|
||||||
|
|
@ -246,8 +261,8 @@ func attachProfile(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
profile := group.Group("/profile")
|
profile := group.Group("/profile")
|
||||||
profile.Use(oauth.Guard)
|
profile.Use(oauth.Guard)
|
||||||
|
|
||||||
profile.GET("/", GinProfileGet) // Get user profile
|
profile.GET("/", GinProfileGet) // Get user profile
|
||||||
profile.PUT("/", placeholder) // Update user profile
|
profile.PUT("/", GinProfileUpdate) // Update user profile
|
||||||
}
|
}
|
||||||
|
|
||||||
// User management (CRUD)
|
// User management (CRUD)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue