mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat: add user profile update feature with moderation support
Add ability for users to update their display name with optional machine/human review. Backend: - Add profile_change_request table (V15 migration) - Add UserProfileService with moderation workflow - Add PATCH /api/v1/user/profile and GET /api/v1/user/profile endpoints - Add ProfileModerationService interface with NoOp implementation - Add ProfileModerationProperties for machine/human review toggles - Update AuthController /me to refresh session when displayName changes - Add i18n messages for profile validation and responses Frontend: - Add /settings/profile page with edit-on-click pattern - Add profileApi.updateProfile() to client - Update user menu: add "Profile Settings", make "Security Settings" local-only - Add i18n translations (en/zh) for profile settings Testing: - Add UserProfileControllerTest with 8 test cases - Add UserProfileServiceTest with 6 test cases - Add AuthControllerTest case for session refresh on displayName change
This commit is contained in:
parent
012ed35a10
commit
297feb59fc
34 changed files with 1581 additions and 7 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -68,9 +68,8 @@ __pycache__/
|
|||
# Superpowers (AI planning artifacts)
|
||||
docs/superpowers/
|
||||
docs/review/
|
||||
docs/requirements/
|
||||
CLAUDE.md
|
||||
|
||||
# oh-my-claudecode
|
||||
.omc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package com.iflytek.skillhub;
|
||||
|
||||
import com.iflytek.skillhub.config.ProfileModerationProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(ProfileModerationProperties.class)
|
||||
public class SkillhubApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SkillhubApplication.class, args);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.config;
|
||||
|
||||
import com.iflytek.skillhub.domain.user.ProfileModerationConfig;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Configuration properties for profile moderation behavior.
|
||||
*
|
||||
* <p>Controls whether machine review and/or human review are enabled
|
||||
* when users update their profile. Both default to {@code false} (open-source mode).
|
||||
*
|
||||
* <p>Configuration combinations:
|
||||
* <pre>
|
||||
* machine=false, human=false → changes apply immediately (open-source default)
|
||||
* machine=true, human=false → machine review only, pass = immediate effect
|
||||
* machine=false, human=true → skip machine review, enter human review queue
|
||||
* machine=true, human=true → machine review first, then human review queue
|
||||
* </pre>
|
||||
*
|
||||
* <p>Implements {@link ProfileModerationConfig} to decouple domain layer from Spring Boot.
|
||||
*
|
||||
* @param machineReview whether to run machine review (e.g. sensitive word detection)
|
||||
* @param humanReview whether to queue changes for human reviewer approval
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "skillhub.profile.moderation")
|
||||
public record ProfileModerationProperties(
|
||||
boolean machineReview,
|
||||
boolean humanReview
|
||||
) implements ProfileModerationConfig {
|
||||
|
||||
/** Default constructor — both switches off (open-source mode). */
|
||||
public ProfileModerationProperties() {
|
||||
this(false, false);
|
||||
}
|
||||
|
||||
/** Returns true if any form of moderation is active. */
|
||||
public boolean isAnyModerationEnabled() {
|
||||
return machineReview || humanReview;
|
||||
}
|
||||
}
|
||||
|
|
@ -84,10 +84,20 @@ public class AuthController extends BaseApiController {
|
|||
userRoleBindingRepository.findByUserId(principal.userId()).stream()
|
||||
.map(binding -> binding.getRole().getCode())
|
||||
.collect(Collectors.toSet()));
|
||||
if (!freshRoles.equals(principal.platformRoles())) {
|
||||
|
||||
// Detect stale session: check if roles or profile fields have changed
|
||||
boolean rolesChanged = !freshRoles.equals(principal.platformRoles());
|
||||
boolean displayNameChanged = !user.getDisplayName().equals(principal.displayName());
|
||||
boolean avatarChanged = !java.util.Objects.equals(user.getAvatarUrl(), principal.avatarUrl());
|
||||
|
||||
if (rolesChanged || displayNameChanged || avatarChanged) {
|
||||
principal = new PlatformPrincipal(
|
||||
principal.userId(), principal.displayName(), principal.email(),
|
||||
principal.avatarUrl(), principal.oauthProvider(), freshRoles);
|
||||
principal.userId(),
|
||||
user.getDisplayName(), // use DB value (may have been updated via profile)
|
||||
principal.email(),
|
||||
user.getAvatarUrl(), // use DB value
|
||||
principal.oauthProvider(),
|
||||
freshRoles);
|
||||
platformSessionService.establishSession(principal, request, false);
|
||||
}
|
||||
return ok("response.success.read", AuthMeResponse.from(principal));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeRequest;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
|
||||
import com.iflytek.skillhub.domain.user.UpdateProfileResult;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserProfileService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PendingChangesResponse;
|
||||
import com.iflytek.skillhub.dto.ProfileUpdateStatus;
|
||||
import com.iflytek.skillhub.dto.UpdateProfileRequest;
|
||||
import com.iflytek.skillhub.dto.UpdateProfileResponse;
|
||||
import com.iflytek.skillhub.dto.UserProfileResponse;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST controller for user profile management.
|
||||
*
|
||||
* <p>Provides endpoints for viewing and updating the current user's profile.
|
||||
* All endpoints require authentication — users can only manage their own profile.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/user/profile")
|
||||
public class UserProfileController extends BaseApiController {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final TypeReference<Map<String, String>> MAP_TYPE = new TypeReference<>() {};
|
||||
|
||||
private final UserProfileService userProfileService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final ProfileChangeRequestRepository changeRequestRepository;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
|
||||
public UserProfileController(ApiResponseFactory responseFactory,
|
||||
UserProfileService userProfileService,
|
||||
UserAccountRepository userAccountRepository,
|
||||
ProfileChangeRequestRepository changeRequestRepository,
|
||||
PlatformSessionService platformSessionService) {
|
||||
super(responseFactory);
|
||||
this.userProfileService = userProfileService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.changeRequestRepository = changeRequestRepository;
|
||||
this.platformSessionService = platformSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's profile, including any pending change request.
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<UserProfileResponse> getProfile(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
requireAuth(principal);
|
||||
|
||||
UserAccount user = userAccountRepository.findById(principal.userId())
|
||||
.orElseThrow(() -> new UnauthorizedException("error.auth.required"));
|
||||
|
||||
// Look up any PENDING change request for this user
|
||||
PendingChangesResponse pendingChanges = changeRequestRepository
|
||||
.findByUserIdAndStatus(principal.userId(), ProfileChangeStatus.PENDING)
|
||||
.stream()
|
||||
.findFirst()
|
||||
.map(this::toPendingChangesResponse)
|
||||
.orElse(null);
|
||||
|
||||
var response = new UserProfileResponse(
|
||||
user.getDisplayName(),
|
||||
user.getAvatarUrl(),
|
||||
user.getEmail(),
|
||||
pendingChanges
|
||||
);
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's profile fields.
|
||||
*
|
||||
* <p>Depending on moderation configuration, changes may be applied
|
||||
* immediately or queued for human review.
|
||||
*/
|
||||
@PatchMapping
|
||||
public ApiResponse<UpdateProfileResponse> updateProfile(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
Authentication authentication,
|
||||
@Valid @RequestBody UpdateProfileRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
requireAuth(principal);
|
||||
|
||||
// Ensure at least one field is provided
|
||||
if (!request.hasChanges()) {
|
||||
throw new IllegalArgumentException("error.profile.noChanges");
|
||||
}
|
||||
|
||||
// Trim displayName if present
|
||||
String displayName = request.displayName() != null
|
||||
? request.displayName().trim()
|
||||
: null;
|
||||
|
||||
// Build changes map from non-null fields
|
||||
Map<String, String> changes = new LinkedHashMap<>();
|
||||
if (displayName != null) {
|
||||
changes.put("displayName", displayName);
|
||||
}
|
||||
|
||||
// Delegate to domain service
|
||||
UpdateProfileResult result = userProfileService.updateProfile(
|
||||
principal.userId(),
|
||||
changes,
|
||||
httpRequest.getHeader("X-Request-Id"),
|
||||
resolveClientIp(httpRequest),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
// Refresh session if changes were applied immediately
|
||||
var response = switch (result) {
|
||||
case UpdateProfileResult.Applied() -> {
|
||||
// Rebuild principal with updated displayName and refresh session
|
||||
refreshSession(principal, authentication, changes, httpRequest);
|
||||
yield new UpdateProfileResponse(
|
||||
ProfileUpdateStatus.APPLIED,
|
||||
"response.profile.updated"
|
||||
);
|
||||
}
|
||||
case UpdateProfileResult.PendingReview() -> new UpdateProfileResponse(
|
||||
ProfileUpdateStatus.PENDING_REVIEW,
|
||||
"response.profile.pendingReview"
|
||||
);
|
||||
};
|
||||
|
||||
return ok("response.success.update", response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session principal after profile changes are applied.
|
||||
* Mirrors the role-refresh pattern in AuthController.me().
|
||||
*/
|
||||
private void refreshSession(PlatformPrincipal principal,
|
||||
Authentication authentication,
|
||||
Map<String, String> changes,
|
||||
HttpServletRequest request) {
|
||||
String newDisplayName = changes.getOrDefault("displayName", principal.displayName());
|
||||
String newAvatarUrl = changes.getOrDefault("avatarUrl", principal.avatarUrl());
|
||||
|
||||
var updatedPrincipal = new PlatformPrincipal(
|
||||
principal.userId(),
|
||||
newDisplayName,
|
||||
principal.email(),
|
||||
newAvatarUrl,
|
||||
principal.oauthProvider(),
|
||||
principal.platformRoles()
|
||||
);
|
||||
platformSessionService.attachToAuthenticatedSession(
|
||||
updatedPrincipal, authentication, request, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ProfileChangeRequest entity to the pending changes response DTO.
|
||||
*/
|
||||
private PendingChangesResponse toPendingChangesResponse(ProfileChangeRequest request) {
|
||||
try {
|
||||
Map<String, String> changes = MAPPER.readValue(request.getChanges(), MAP_TYPE);
|
||||
return new PendingChangesResponse(
|
||||
request.getStatus().name(),
|
||||
changes,
|
||||
request.getCreatedAt()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
// Malformed JSON in DB — return null rather than breaking the GET endpoint
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve client IP from proxy headers or direct connection. */
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
/** Guard — throw 401 if principal is missing. */
|
||||
private void requireAuth(PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Pending profile changes awaiting human review.
|
||||
* Null when no PENDING request exists for the user.
|
||||
*
|
||||
* @param status always "PENDING" when present
|
||||
* @param changes map of field name → requested new value
|
||||
* @param createdAt when the change request was submitted
|
||||
*/
|
||||
public record PendingChangesResponse(
|
||||
String status,
|
||||
Map<String, String> changes,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/**
|
||||
* Status of a profile update operation, returned to the frontend.
|
||||
*/
|
||||
public enum ProfileUpdateStatus {
|
||||
/** Changes were applied immediately to user_account. */
|
||||
APPLIED,
|
||||
/** Changes are queued for human review (not yet applied). */
|
||||
PENDING_REVIEW
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request DTO for updating user profile fields.
|
||||
*
|
||||
* <p>All fields are optional — the caller supplies only the fields they want to change.
|
||||
* At least one non-null field must be present (validated in the controller).
|
||||
*
|
||||
* <p>Validation rules for displayName:
|
||||
* <ul>
|
||||
* <li>Length: 2–32 characters (after trim)</li>
|
||||
* <li>Allowed characters: Chinese, English, digits, underscore, hyphen</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param displayName new display name (nullable — omit to leave unchanged)
|
||||
*/
|
||||
public record UpdateProfileRequest(
|
||||
@Size(min = 2, max = 32, message = "error.profile.displayName.length")
|
||||
@Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_-]+$",
|
||||
message = "error.profile.displayName.pattern")
|
||||
String displayName
|
||||
) {
|
||||
/**
|
||||
* Returns true if at least one field is provided.
|
||||
* Future fields (avatarUrl, etc.) should be added to this check.
|
||||
*/
|
||||
public boolean hasChanges() {
|
||||
return displayName != null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/**
|
||||
* Response DTO for profile update operations.
|
||||
*
|
||||
* @param status whether the change was applied immediately or queued for review
|
||||
* @param message human-readable status message (i18n key resolved by frontend)
|
||||
*/
|
||||
public record UpdateProfileResponse(
|
||||
ProfileUpdateStatus status,
|
||||
String message
|
||||
) {}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/**
|
||||
* Response DTO for GET /api/v1/user/profile.
|
||||
*
|
||||
* <p>Returns the current (approved) profile values plus any pending
|
||||
* change request awaiting review.
|
||||
*
|
||||
* @param displayName current approved display name
|
||||
* @param avatarUrl current approved avatar URL
|
||||
* @param email user email (read-only, not editable via profile)
|
||||
* @param pendingChanges pending change request details, or null if none
|
||||
*/
|
||||
public record UserProfileResponse(
|
||||
String displayName,
|
||||
String avatarUrl,
|
||||
String email,
|
||||
PendingChangesResponse pendingChanges
|
||||
) {}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.user.ModerationResult;
|
||||
import com.iflytek.skillhub.domain.user.ProfileModerationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Default (no-op) profile moderation service for open-source deployments.
|
||||
*
|
||||
* <p>Always returns {@link ModerationResult#approved()}, meaning profile
|
||||
* changes take effect immediately without any review.
|
||||
*
|
||||
* <p>SaaS deployments can override by providing their own
|
||||
* {@link ProfileModerationService} bean annotated with {@code @Primary}.
|
||||
*/
|
||||
@Service
|
||||
public class NoOpProfileModerationService implements ProfileModerationService {
|
||||
|
||||
@Override
|
||||
public ModerationResult moderate(String userId, Map<String, String> changes) {
|
||||
return ModerationResult.approved();
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,10 @@ skillhub:
|
|||
max-single-file-size: 10485760 # 10MB
|
||||
max-package-size: 104857600 # 100MB
|
||||
allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.html,.css,.csv,.pdf,.toml,.xml,.ini,.cfg,.env,.js,.ts,.py,.sh,.rb,.go,.rs,.java,.kt,.lua,.sql,.r,.bat,.ps1,.zsh,.bash,.png,.jpg,.jpeg,.svg,.gif,.webp,.ico
|
||||
profile:
|
||||
moderation:
|
||||
machine-review: false # Enable machine review (e.g. sensitive word detection)
|
||||
human-review: false # Enable human review queue
|
||||
device-auth:
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
|
||||
bootstrap:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
-- Profile change request table.
|
||||
-- Tracks user-initiated profile modifications (display name, avatar, etc.)
|
||||
-- with optional machine and human review workflow.
|
||||
-- The 'changes' and 'old_values' columns use JSONB to support batch field updates
|
||||
-- in a single request, e.g. {"displayName": "new name", "avatarUrl": "https://..."}
|
||||
|
||||
CREATE TABLE profile_change_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
|
||||
changes JSONB NOT NULL, -- requested field changes (key = field name, value = new value)
|
||||
old_values JSONB, -- snapshot of previous values before this change
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
-- PENDING | MACHINE_REJECTED | APPROVED | REJECTED | CANCELLED
|
||||
machine_result VARCHAR(32), -- PASS | FAIL | SKIPPED
|
||||
machine_reason TEXT, -- rejection reason from machine review
|
||||
reviewer_id VARCHAR(128) REFERENCES user_account(id), -- human reviewer who acted on this request
|
||||
review_comment TEXT, -- human reviewer's comment
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP -- timestamp when human review was completed
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pcr_user_id ON profile_change_request(user_id);
|
||||
CREATE INDEX idx_pcr_status ON profile_change_request(status);
|
||||
CREATE INDEX idx_pcr_created ON profile_change_request(created_at DESC);
|
||||
CREATE INDEX idx_pcr_changes ON profile_change_request USING GIN (changes);
|
||||
|
|
@ -124,3 +124,11 @@ error.admin.user.status.invalid=Invalid user status: {0}
|
|||
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
|
||||
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
|
||||
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace
|
||||
|
||||
# Profile update
|
||||
error.profile.displayName.length=Display name must be between 2 and 32 characters
|
||||
error.profile.displayName.pattern=Display name can only contain Chinese characters, English letters, numbers, underscores, and hyphens
|
||||
error.profile.noChanges=At least one field must be provided
|
||||
response.profile.updated=Profile updated successfully
|
||||
response.profile.pendingReview=Profile changes submitted for review
|
||||
|
||||
|
|
|
|||
|
|
@ -124,3 +124,11 @@ error.admin.user.status.invalid=无效的用户状态:{0}
|
|||
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
|
||||
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
|
||||
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能
|
||||
|
||||
# 用户资料修改
|
||||
error.profile.displayName.length=昵称长度需在 2-32 个字符之间
|
||||
error.profile.displayName.pattern=昵称仅允许中文、英文、数字、下划线和连字符
|
||||
error.profile.noChanges=至少需要提供一个修改字段
|
||||
response.profile.updated=资料已更新
|
||||
response.profile.pendingReview=资料变更已提交审核
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,36 @@ class AuthControllerTest {
|
|||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
|
||||
// ===== AC-P-002: Session refresh when displayName changes =====
|
||||
|
||||
@Test
|
||||
void meShouldRefreshSessionWhenDisplayNameChanges() throws Exception {
|
||||
given(namespaceMemberRepository.findByUserId("user-42")).willReturn(List.of());
|
||||
var user = new UserAccount("user-42", "UpdatedName", "tester@example.com", "https://example.com/avatar.png");
|
||||
given(userAccountRepository.findById("user-42")).willReturn(java.util.Optional.of(user));
|
||||
given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of());
|
||||
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"OldName", // stale displayName in session
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("USER")
|
||||
);
|
||||
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.displayName").value("UpdatedName")); // should return DB value
|
||||
}
|
||||
|
||||
@Test
|
||||
void providersShouldExposeGithubLoginEntry() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/auth/providers"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link UserProfileController}.
|
||||
* Uses MockMvc with Spring Security context to test the full HTTP flow.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = {
|
||||
"spring.security.oauth2.client.registration.github.client-name=GitHub",
|
||||
"spring.security.oauth2.client.registration.gitee.client-id=placeholder",
|
||||
"spring.security.oauth2.client.registration.gitee.client-secret=placeholder",
|
||||
"spring.security.oauth2.client.registration.gitee.provider=gitee",
|
||||
"spring.security.oauth2.client.registration.gitee.authorization-grant-type=authorization_code",
|
||||
"spring.security.oauth2.client.registration.gitee.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||
"spring.security.oauth2.client.registration.gitee.scope=user_info",
|
||||
"spring.security.oauth2.client.registration.gitee.client-name=Gitee",
|
||||
"spring.security.oauth2.client.provider.gitee.authorization-uri=https://gitee.com/oauth/authorize",
|
||||
"spring.security.oauth2.client.provider.gitee.token-uri=https://gitee.com/oauth/token",
|
||||
"spring.security.oauth2.client.provider.gitee.user-info-uri=https://gitee.com/api/v5/user",
|
||||
"spring.security.oauth2.client.provider.gitee.user-name-attribute=id",
|
||||
"skillhub.profile.moderation.machine-review=false",
|
||||
"skillhub.profile.moderation.human-review=false"
|
||||
})
|
||||
class UserProfileControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@MockBean
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@MockBean
|
||||
private UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
@MockBean
|
||||
private ProfileChangeRequestRepository changeRequestRepository;
|
||||
|
||||
@MockBean
|
||||
private PlatformSessionService platformSessionService;
|
||||
|
||||
// -- Helper --
|
||||
|
||||
private PlatformPrincipal testPrincipal() {
|
||||
return new PlatformPrincipal(
|
||||
"user-1", "OldName", "user@example.com",
|
||||
"https://example.com/avatar.png", "github", Set.of("USER"));
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken testAuth(PlatformPrincipal principal) {
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
}
|
||||
|
||||
// ===== AC-S-001: Unauthorized access to PATCH =====
|
||||
|
||||
@Test
|
||||
void updateProfile_unauthenticated_shouldReturn401() throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"NewName\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// ===== AC-S-002: Unauthorized access to GET =====
|
||||
|
||||
@Test
|
||||
void getProfile_unauthenticated_shouldReturn401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/user/profile"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// ===== AC-P-001: Successful update =====
|
||||
|
||||
@Test
|
||||
void updateProfile_validRequest_shouldReturn200() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
var user = new UserAccount("user-1", "OldName", "user@example.com", "https://example.com/avatar.png");
|
||||
|
||||
given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user));
|
||||
given(namespaceMemberRepository.findByUserId("user-1")).willReturn(List.of());
|
||||
given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of());
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"NewName\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPLIED"));
|
||||
}
|
||||
|
||||
// ===== AC-E-001: Display name too short =====
|
||||
|
||||
@Test
|
||||
void updateProfile_displayNameTooShort_shouldReturn400() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"A\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ===== AC-E-002: Display name too long =====
|
||||
|
||||
@Test
|
||||
void updateProfile_displayNameTooLong_shouldReturn400() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
String longName = "a".repeat(33);
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"" + longName + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ===== AC-E-003: Invalid characters =====
|
||||
|
||||
@Test
|
||||
void updateProfile_invalidCharacters_shouldReturn400() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"test@user!\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ===== AC-E-006: Empty request body =====
|
||||
|
||||
@Test
|
||||
void updateProfile_emptyRequest_shouldReturn400() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
var user = new UserAccount("user-1", "OldName", "user@example.com", "https://example.com/avatar.png");
|
||||
|
||||
given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ===== AC-P-006: GET profile with no pending changes =====
|
||||
|
||||
@Test
|
||||
void getProfile_noPendingChanges_shouldReturnCurrentValues() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
var user = new UserAccount("user-1", "CurrentName", "user@example.com", "https://example.com/avatar.png");
|
||||
|
||||
given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user));
|
||||
given(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
|
||||
.willReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.displayName").value("CurrentName"))
|
||||
.andExpect(jsonPath("$.data.email").value("user@example.com"))
|
||||
.andExpect(jsonPath("$.data.pendingChanges").isEmpty());
|
||||
}
|
||||
|
||||
// ===== AC-S-003: XSS attempt =====
|
||||
|
||||
@Test
|
||||
void updateProfile_xssAttempt_shouldReturn400() throws Exception {
|
||||
var principal = testPrincipal();
|
||||
|
||||
mockMvc.perform(patch("/api/v1/user/profile")
|
||||
.with(authentication(testAuth(principal)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"displayName\":\"<script>alert('xss')</script>\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
/**
|
||||
* Outcome of a profile moderation check.
|
||||
*
|
||||
* <p>Used as a sealed hierarchy so callers must handle all cases
|
||||
* via pattern matching (Java 21 switch expressions).
|
||||
*/
|
||||
public enum ModerationDecision {
|
||||
/** Change is approved — apply immediately. */
|
||||
APPROVED,
|
||||
/** Change is rejected — return error to user. */
|
||||
REJECTED,
|
||||
/** Change needs human review — queue for reviewer. */
|
||||
NEEDS_REVIEW
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
/**
|
||||
* Result of a profile moderation check, combining the decision with an optional reason.
|
||||
*
|
||||
* @param decision the moderation outcome
|
||||
* @param reason human-readable reason (populated on REJECTED; null otherwise)
|
||||
*/
|
||||
public record ModerationResult(ModerationDecision decision, String reason) {
|
||||
|
||||
/** Convenience factory — change approved, no reason needed. */
|
||||
public static ModerationResult approved() {
|
||||
return new ModerationResult(ModerationDecision.APPROVED, null);
|
||||
}
|
||||
|
||||
/** Convenience factory — change rejected with a reason. */
|
||||
public static ModerationResult rejected(String reason) {
|
||||
return new ModerationResult(ModerationDecision.REJECTED, reason);
|
||||
}
|
||||
|
||||
/** Convenience factory — change needs human review. */
|
||||
public static ModerationResult needsReview() {
|
||||
return new ModerationResult(ModerationDecision.NEEDS_REVIEW, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Represents a user-initiated profile change request.
|
||||
*
|
||||
* <p>Each request captures a batch of field changes as JSONB (e.g. displayName, avatarUrl),
|
||||
* along with the previous values for audit and rollback purposes.
|
||||
*
|
||||
* <p>Depending on the moderation configuration, a request may be:
|
||||
* <ul>
|
||||
* <li>Immediately approved (no moderation)</li>
|
||||
* <li>Rejected by machine review</li>
|
||||
* <li>Queued for human review (PENDING)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see ProfileChangeStatus for the full lifecycle
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "profile_change_request")
|
||||
public class ProfileChangeRequest {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The user who initiated this change request. */
|
||||
@Column(name = "user_id", nullable = false, length = 128)
|
||||
private String userId;
|
||||
|
||||
/** Requested changes as JSON, e.g. {"displayName": "new name"}. */
|
||||
@Column(nullable = false)
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String changes;
|
||||
|
||||
/** Snapshot of previous values before this change, e.g. {"displayName": "old name"}. */
|
||||
@Column(name = "old_values")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String oldValues;
|
||||
|
||||
/** Current status in the review lifecycle. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private ProfileChangeStatus status = ProfileChangeStatus.PENDING;
|
||||
|
||||
/** Machine review outcome: PASS, FAIL, or SKIPPED. */
|
||||
@Column(name = "machine_result", length = 32)
|
||||
private String machineResult;
|
||||
|
||||
/** Reason provided by machine review when rejected. */
|
||||
@Column(name = "machine_reason")
|
||||
private String machineReason;
|
||||
|
||||
/** User ID of the human reviewer who acted on this request. */
|
||||
@Column(name = "reviewer_id", length = 128)
|
||||
private String reviewerId;
|
||||
|
||||
/** Comment left by the human reviewer. */
|
||||
@Column(name = "review_comment")
|
||||
private String reviewComment;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
/** Timestamp when human review was completed. */
|
||||
@Column(name = "reviewed_at")
|
||||
private Instant reviewedAt;
|
||||
|
||||
protected ProfileChangeRequest() {}
|
||||
|
||||
public ProfileChangeRequest(String userId, String changes, String oldValues,
|
||||
ProfileChangeStatus status, String machineResult,
|
||||
String machineReason) {
|
||||
this.userId = userId;
|
||||
this.changes = changes;
|
||||
this.oldValues = oldValues;
|
||||
this.status = status;
|
||||
this.machineResult = machineResult;
|
||||
this.machineReason = machineReason;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getUserId() { return userId; }
|
||||
public String getChanges() { return changes; }
|
||||
public String getOldValues() { return oldValues; }
|
||||
public ProfileChangeStatus getStatus() { return status; }
|
||||
public String getMachineResult() { return machineResult; }
|
||||
public String getMachineReason() { return machineReason; }
|
||||
public String getReviewerId() { return reviewerId; }
|
||||
public String getReviewComment() { return reviewComment; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getReviewedAt() { return reviewedAt; }
|
||||
|
||||
// -- Setters (only for mutable fields) --
|
||||
|
||||
public void setStatus(ProfileChangeStatus status) { this.status = status; }
|
||||
public void setReviewerId(String reviewerId) { this.reviewerId = reviewerId; }
|
||||
public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
|
||||
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository for {@link ProfileChangeRequest} entities.
|
||||
* Implementations are provided by the infra layer (JPA).
|
||||
*/
|
||||
public interface ProfileChangeRequestRepository {
|
||||
|
||||
ProfileChangeRequest save(ProfileChangeRequest request);
|
||||
|
||||
Optional<ProfileChangeRequest> findById(Long id);
|
||||
|
||||
/**
|
||||
* Find all requests for a given user with a specific status.
|
||||
* Primarily used to locate PENDING requests when a user submits
|
||||
* a new change (so the old PENDING ones can be cancelled).
|
||||
*/
|
||||
List<ProfileChangeRequest> findByUserIdAndStatus(String userId, ProfileChangeStatus status);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
/**
|
||||
* Status of a profile change request throughout its lifecycle.
|
||||
*
|
||||
* <p>State transitions:
|
||||
* <pre>
|
||||
* PENDING ──→ APPROVED (human reviewer approves)
|
||||
* PENDING ──→ REJECTED (human reviewer rejects)
|
||||
* PENDING ──→ CANCELLED (user submits a new request, replacing this one)
|
||||
* (direct) ──→ MACHINE_REJECTED (machine review rejects before entering queue)
|
||||
* </pre>
|
||||
*/
|
||||
public enum ProfileChangeStatus {
|
||||
/** Awaiting human review. */
|
||||
PENDING,
|
||||
/** Rejected by machine review (e.g. sensitive word detection). */
|
||||
MACHINE_REJECTED,
|
||||
/** Approved and applied to user_account. */
|
||||
APPROVED,
|
||||
/** Rejected by human reviewer. */
|
||||
REJECTED,
|
||||
/** Superseded by a newer request from the same user. */
|
||||
CANCELLED
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
/**
|
||||
* Domain-level abstraction for profile moderation configuration.
|
||||
*
|
||||
* <p>Decouples the domain service from Spring Boot's
|
||||
* {@code @ConfigurationProperties}. The app layer provides
|
||||
* the concrete implementation backed by application.yml.
|
||||
*/
|
||||
public interface ProfileModerationConfig {
|
||||
|
||||
/** Whether machine review (e.g. sensitive word detection) is enabled. */
|
||||
boolean machineReview();
|
||||
|
||||
/** Whether human review queue is enabled. */
|
||||
boolean humanReview();
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Pluggable moderation service for user profile changes.
|
||||
*
|
||||
* <p>Implementations are provided at the application layer and injected
|
||||
* into domain services. The open-source default is a no-op that always
|
||||
* approves; SaaS deployments can supply a machine-review implementation.
|
||||
*/
|
||||
public interface ProfileModerationService {
|
||||
|
||||
/**
|
||||
* Evaluate proposed profile changes against moderation rules.
|
||||
*
|
||||
* @param userId the user requesting the change
|
||||
* @param changes map of field name → new value (e.g. "displayName" → "new name")
|
||||
* @return moderation result indicating whether to approve, reject, or queue for review
|
||||
*/
|
||||
ModerationResult moderate(String userId, Map<String, String> changes);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
/**
|
||||
* Result of a profile update operation.
|
||||
*
|
||||
* <p>Uses a sealed interface with record implementations (Java 17+)
|
||||
* to enable exhaustive pattern matching in callers.
|
||||
*
|
||||
* @see UserProfileService#updateProfile
|
||||
*/
|
||||
public sealed interface UpdateProfileResult {
|
||||
|
||||
/** Changes were applied immediately to user_account. */
|
||||
record Applied() implements UpdateProfileResult {}
|
||||
|
||||
/** Changes are queued for human review (not yet applied). */
|
||||
record PendingReview() implements UpdateProfileResult {}
|
||||
|
||||
/** Convenience factory for the applied case. */
|
||||
static UpdateProfileResult applied() {
|
||||
return new Applied();
|
||||
}
|
||||
|
||||
/** Convenience factory for the pending-review case. */
|
||||
static UpdateProfileResult pendingReview() {
|
||||
return new PendingReview();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Core service for user profile updates.
|
||||
*
|
||||
* <p>Orchestrates the full update flow: validation → machine review →
|
||||
* human review (if configured) → apply changes → audit logging.
|
||||
*
|
||||
* <p>The moderation behavior is driven by {@link ProfileModerationConfig}:
|
||||
* when both switches are off, changes apply immediately (open-source default).
|
||||
*/
|
||||
@Service
|
||||
public class UserProfileService {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final ProfileChangeRequestRepository changeRequestRepository;
|
||||
private final ProfileModerationService moderationService;
|
||||
private final ProfileModerationConfig moderationConfig;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public UserProfileService(UserAccountRepository userAccountRepository,
|
||||
ProfileChangeRequestRepository changeRequestRepository,
|
||||
ProfileModerationService moderationService,
|
||||
ProfileModerationConfig moderationConfig,
|
||||
AuditLogService auditLogService) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.changeRequestRepository = changeRequestRepository;
|
||||
this.moderationService = moderationService;
|
||||
this.moderationConfig = moderationConfig;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile fields (e.g. displayName, avatarUrl).
|
||||
*
|
||||
* <p>Depending on moderation config, this may:
|
||||
* <ul>
|
||||
* <li>Apply changes immediately (no moderation)</li>
|
||||
* <li>Reject via machine review</li>
|
||||
* <li>Queue for human review (PENDING)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param userId the user making the change
|
||||
* @param changes map of field name → new value
|
||||
* @param requestId HTTP request ID for audit trail
|
||||
* @param clientIp client IP address
|
||||
* @param userAgent client user agent
|
||||
* @return result indicating whether changes were applied or queued
|
||||
*/
|
||||
@Transactional
|
||||
public UpdateProfileResult updateProfile(String userId,
|
||||
Map<String, String> changes,
|
||||
String requestId,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
// 1. Build snapshot of old values for audit and rollback
|
||||
Map<String, String> oldValues = buildOldValues(user, changes);
|
||||
|
||||
// 2. Machine review (if enabled)
|
||||
if (moderationConfig.machineReview()) {
|
||||
ModerationResult machineResult = moderationService.moderate(userId, changes);
|
||||
if (machineResult.decision() == ModerationDecision.REJECTED) {
|
||||
saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.MACHINE_REJECTED,
|
||||
"FAIL", machineResult.reason());
|
||||
throw new IllegalArgumentException(machineResult.reason());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Human review (if enabled)
|
||||
if (moderationConfig.humanReview()) {
|
||||
cancelPendingRequests(userId); // Replace any existing PENDING request
|
||||
saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.PENDING,
|
||||
moderationConfig.machineReview() ? "PASS" : "SKIPPED", null);
|
||||
return UpdateProfileResult.pendingReview();
|
||||
}
|
||||
|
||||
// 4. No moderation — apply changes immediately
|
||||
applyChanges(user, changes);
|
||||
saveChangeRequest(userId, changes, oldValues, ProfileChangeStatus.APPROVED,
|
||||
moderationConfig.machineReview() ? "PASS" : "SKIPPED", null);
|
||||
|
||||
// 5. Audit log
|
||||
auditLogService.record(userId, "PROFILE_UPDATE", "USER", null,
|
||||
requestId, clientIp, userAgent,
|
||||
toJson(Map.of("changes", changes, "oldValues", oldValues)));
|
||||
|
||||
return UpdateProfileResult.applied();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply approved changes to the user account.
|
||||
* Extensible for future fields (avatarUrl, etc.).
|
||||
*/
|
||||
private void applyChanges(UserAccount user, Map<String, String> changes) {
|
||||
if (changes.containsKey("displayName")) {
|
||||
user.setDisplayName(changes.get("displayName"));
|
||||
}
|
||||
// Future: avatarUrl, etc.
|
||||
userAccountRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any existing PENDING requests for this user.
|
||||
* Called when a user submits a new change, superseding the old one.
|
||||
*/
|
||||
private void cancelPendingRequests(String userId) {
|
||||
changeRequestRepository.findByUserIdAndStatus(userId, ProfileChangeStatus.PENDING)
|
||||
.forEach(req -> {
|
||||
req.setStatus(ProfileChangeStatus.CANCELLED);
|
||||
changeRequestRepository.save(req);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a snapshot of the current values for fields being changed.
|
||||
* Used for audit trail and potential rollback.
|
||||
*/
|
||||
private Map<String, String> buildOldValues(UserAccount user, Map<String, String> changes) {
|
||||
Map<String, String> oldValues = new LinkedHashMap<>();
|
||||
if (changes.containsKey("displayName")) {
|
||||
oldValues.put("displayName", user.getDisplayName());
|
||||
}
|
||||
// Future: avatarUrl, etc.
|
||||
return oldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a change request record for audit and review purposes.
|
||||
*/
|
||||
private void saveChangeRequest(String userId, Map<String, String> changes,
|
||||
Map<String, String> oldValues, ProfileChangeStatus status,
|
||||
String machineResult, String machineReason) {
|
||||
ProfileChangeRequest request = new ProfileChangeRequest(
|
||||
userId,
|
||||
toJson(changes),
|
||||
toJson(oldValues),
|
||||
status,
|
||||
machineResult,
|
||||
machineReason
|
||||
);
|
||||
changeRequestRepository.save(request);
|
||||
}
|
||||
|
||||
private String toJson(Object obj) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(obj);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize to JSON", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link UserProfileService}.
|
||||
* Covers the core update flow under different moderation configurations.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserProfileServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Mock
|
||||
private ProfileChangeRequestRepository changeRequestRepository;
|
||||
|
||||
@Mock
|
||||
private ProfileModerationService moderationService;
|
||||
|
||||
@Mock
|
||||
private ProfileModerationConfig moderationConfig;
|
||||
|
||||
@Mock
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@InjectMocks
|
||||
private UserProfileService userProfileService;
|
||||
|
||||
// -- Helper --
|
||||
|
||||
private UserAccount testUser() {
|
||||
return new UserAccount("user-1", "OldName", "user@example.com", "https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
private Map<String, String> displayNameChange(String newName) {
|
||||
return Map.of("displayName", newName);
|
||||
}
|
||||
|
||||
// ===== AC-P-001: Successful update with no moderation =====
|
||||
|
||||
@Test
|
||||
void updateProfile_noModeration_shouldApplyImmediately() {
|
||||
var user = testUser();
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(false);
|
||||
when(moderationConfig.humanReview()).thenReturn(false);
|
||||
|
||||
var result = userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
|
||||
|
||||
// Should return Applied
|
||||
assertInstanceOf(UpdateProfileResult.Applied.class, result);
|
||||
|
||||
// user_account should be updated
|
||||
assertEquals("NewName", user.getDisplayName());
|
||||
verify(userAccountRepository).save(user);
|
||||
|
||||
// Change request should be saved as APPROVED
|
||||
var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
|
||||
verify(changeRequestRepository).save(captor.capture());
|
||||
assertEquals(ProfileChangeStatus.APPROVED, captor.getValue().getStatus());
|
||||
|
||||
// Audit log should be recorded
|
||||
verify(auditLogService).record(eq("user-1"), eq("PROFILE_UPDATE"),
|
||||
eq("USER"), isNull(), eq("req-1"), eq("127.0.0.1"), eq("TestAgent"), any());
|
||||
}
|
||||
|
||||
// ===== AC-P-003: Same value (idempotent) =====
|
||||
|
||||
@Test
|
||||
void updateProfile_sameValue_shouldSucceed() {
|
||||
var user = testUser();
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(false);
|
||||
when(moderationConfig.humanReview()).thenReturn(false);
|
||||
|
||||
var result = userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("OldName"), "req-1", "127.0.0.1", "TestAgent");
|
||||
|
||||
assertInstanceOf(UpdateProfileResult.Applied.class, result);
|
||||
assertEquals("OldName", user.getDisplayName());
|
||||
}
|
||||
|
||||
// ===== AC-P-004: Human review enabled — creates PENDING request =====
|
||||
|
||||
@Test
|
||||
void updateProfile_humanReviewEnabled_shouldCreatePendingRequest() {
|
||||
var user = testUser();
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(false);
|
||||
when(moderationConfig.humanReview()).thenReturn(true);
|
||||
when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
|
||||
|
||||
// Should return PendingReview
|
||||
assertInstanceOf(UpdateProfileResult.PendingReview.class, result);
|
||||
|
||||
// user_account should NOT be updated
|
||||
assertEquals("OldName", user.getDisplayName());
|
||||
verify(userAccountRepository, never()).save(any());
|
||||
|
||||
// Change request should be saved as PENDING
|
||||
var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
|
||||
verify(changeRequestRepository).save(captor.capture());
|
||||
assertEquals(ProfileChangeStatus.PENDING, captor.getValue().getStatus());
|
||||
assertEquals("SKIPPED", captor.getValue().getMachineResult());
|
||||
|
||||
// No audit log for pending requests
|
||||
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ===== AC-P-005: Overwrite existing PENDING request =====
|
||||
|
||||
@Test
|
||||
void updateProfile_existingPending_shouldCancelOldAndCreateNew() {
|
||||
var user = testUser();
|
||||
var oldRequest = new ProfileChangeRequest("user-1", "{\"displayName\":\"PendingName\"}",
|
||||
"{\"displayName\":\"OldName\"}", ProfileChangeStatus.PENDING, "SKIPPED", null);
|
||||
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(false);
|
||||
when(moderationConfig.humanReview()).thenReturn(true);
|
||||
when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
|
||||
.thenReturn(List.of(oldRequest));
|
||||
|
||||
userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("NewerName"), "req-1", "127.0.0.1", "TestAgent");
|
||||
|
||||
// Old request should be cancelled
|
||||
assertEquals(ProfileChangeStatus.CANCELLED, oldRequest.getStatus());
|
||||
|
||||
// Two saves: one for cancel, one for new request
|
||||
verify(changeRequestRepository, times(2)).save(any(ProfileChangeRequest.class));
|
||||
}
|
||||
|
||||
// ===== Machine review: pass then human review =====
|
||||
|
||||
@Test
|
||||
void updateProfile_machinePassAndHumanReview_shouldCreatePendingWithPassResult() {
|
||||
var user = testUser();
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(true);
|
||||
when(moderationConfig.humanReview()).thenReturn(true);
|
||||
when(moderationService.moderate("user-1", displayNameChange("NewName")))
|
||||
.thenReturn(ModerationResult.approved());
|
||||
when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
|
||||
|
||||
assertInstanceOf(UpdateProfileResult.PendingReview.class, result);
|
||||
|
||||
var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
|
||||
verify(changeRequestRepository).save(captor.capture());
|
||||
assertEquals("PASS", captor.getValue().getMachineResult());
|
||||
}
|
||||
|
||||
// ===== Machine review: rejected =====
|
||||
|
||||
@Test
|
||||
void updateProfile_machineRejected_shouldThrowAndSaveRejection() {
|
||||
var user = testUser();
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(moderationConfig.machineReview()).thenReturn(true);
|
||||
when(moderationService.moderate("user-1", displayNameChange("BadWord")))
|
||||
.thenReturn(ModerationResult.rejected("Contains sensitive content"));
|
||||
|
||||
var ex = assertThrows(IllegalArgumentException.class, () ->
|
||||
userProfileService.updateProfile(
|
||||
"user-1", displayNameChange("BadWord"), "req-1", "127.0.0.1", "TestAgent"));
|
||||
|
||||
assertEquals("Contains sensitive content", ex.getMessage());
|
||||
|
||||
// Change request should be saved as MACHINE_REJECTED
|
||||
var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class);
|
||||
verify(changeRequestRepository).save(captor.capture());
|
||||
assertEquals(ProfileChangeStatus.MACHINE_REJECTED, captor.getValue().getStatus());
|
||||
assertEquals("FAIL", captor.getValue().getMachineResult());
|
||||
|
||||
// user_account should NOT be updated
|
||||
verify(userAccountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ===== User not found =====
|
||||
|
||||
@Test
|
||||
void updateProfile_userNotFound_shouldThrow() {
|
||||
when(userAccountRepository.findById("nonexistent")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
userProfileService.updateProfile(
|
||||
"nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.infra.jpa;
|
||||
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeRequest;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeRequestRepository;
|
||||
import com.iflytek.skillhub.domain.user.ProfileChangeStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JPA implementation of {@link ProfileChangeRequestRepository}.
|
||||
* Spring Data derives query methods from method names automatically.
|
||||
*/
|
||||
@Repository
|
||||
public interface ProfileChangeRequestJpaRepository
|
||||
extends JpaRepository<ProfileChangeRequest, Long>, ProfileChangeRequestRepository {
|
||||
|
||||
@Override
|
||||
List<ProfileChangeRequest> findByUserIdAndStatus(String userId, ProfileChangeStatus status);
|
||||
}
|
||||
|
|
@ -858,6 +858,18 @@ export const meApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
async updateProfile(request: { displayName: string }): Promise<{ status: string }> {
|
||||
return fetchJson<{ status: string }>('/api/v1/user/profile', {
|
||||
method: 'PATCH',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ const SecuritySettingsPage = createLazyRouteComponent(
|
|||
() => import('@/pages/settings/security'),
|
||||
'SecuritySettingsPage',
|
||||
)
|
||||
const ProfileSettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/profile'),
|
||||
'ProfileSettingsPage',
|
||||
)
|
||||
const AdminUsersPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/admin/users'),
|
||||
'AdminUsersPage',
|
||||
|
|
@ -327,6 +331,13 @@ const settingsSecurityRoute = createRoute({
|
|||
component: SecuritySettingsPage,
|
||||
})
|
||||
|
||||
const settingsProfileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'settings/profile',
|
||||
beforeLoad: requireAuth,
|
||||
component: ProfileSettingsPage,
|
||||
})
|
||||
|
||||
const settingsAccountsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'settings/accounts',
|
||||
|
|
@ -375,6 +386,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardTokensRoute,
|
||||
cliAuthRoute,
|
||||
settingsSecurityRoute,
|
||||
settingsProfileRoute,
|
||||
settingsAccountsRoute,
|
||||
adminUsersRoute,
|
||||
adminAuditLogRoute,
|
||||
|
|
|
|||
|
|
@ -509,6 +509,25 @@
|
|||
"prevPage": "Previous",
|
||||
"nextPage": "Next"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
"subtitle": "Manage your display name and personal information.",
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel",
|
||||
"successTitle": "Profile Updated",
|
||||
"successDescription": "Your display name has been updated.",
|
||||
"pendingReviewTitle": "Submitted for Review",
|
||||
"pendingReviewDescription": "Your display name change is pending review and will take effect once approved.",
|
||||
"defaultError": "Failed to update profile. Please try again.",
|
||||
"validation": {
|
||||
"length": "Display name must be 2-32 characters.",
|
||||
"pattern": "Display name can only contain Chinese, English, digits, underscores, and hyphens."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"title": "Security Settings",
|
||||
"subtitle": "Update your password when local account login is enabled.",
|
||||
|
|
@ -890,6 +909,7 @@
|
|||
"users": "User Management",
|
||||
"auditLog": "Audit Log",
|
||||
"security": "Security Settings",
|
||||
"profile": "Profile Settings",
|
||||
"accounts": "Account Merge",
|
||||
"logout": "Logout"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,6 +509,25 @@
|
|||
"prevPage": "上一页",
|
||||
"nextPage": "下一页"
|
||||
},
|
||||
"profile": {
|
||||
"title": "个人设置",
|
||||
"subtitle": "管理你的昵称和个人信息。",
|
||||
"displayName": "昵称",
|
||||
"email": "邮箱",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"cancel": "取消",
|
||||
"successTitle": "修改成功",
|
||||
"successDescription": "你的昵称已更新。",
|
||||
"pendingReviewTitle": "已提交审核",
|
||||
"pendingReviewDescription": "你的昵称修改正在等待审核,审核通过后将生效。",
|
||||
"defaultError": "修改失败,请稍后重试。",
|
||||
"validation": {
|
||||
"length": "昵称长度需为 2-32 个字符。",
|
||||
"pattern": "昵称只能包含中文、英文、数字、下划线和连字符。"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"title": "安全设置",
|
||||
"subtitle": "已启用本地账号密码登录时,可以在这里更新密码。",
|
||||
|
|
@ -890,6 +909,7 @@
|
|||
"users": "用户管理",
|
||||
"auditLog": "审计日志",
|
||||
"security": "安全设置",
|
||||
"profile": "个人设置",
|
||||
"accounts": "账号合并",
|
||||
"logout": "退出登录"
|
||||
}
|
||||
|
|
|
|||
153
web/src/pages/settings/profile.tsx
Normal file
153
web/src/pages/settings/profile.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, profileApi } from '@/api/client'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { truncateErrorMessage } from '@/shared/lib/error-display'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
|
||||
/** Regex matching allowed display name characters: Chinese, English, digits, underscore, hyphen. */
|
||||
const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_-]+$/
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function handleEdit() {
|
||||
setDisplayName(user?.displayName ?? '')
|
||||
setErrorMessage('')
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setIsEditing(false)
|
||||
setErrorMessage('')
|
||||
}
|
||||
|
||||
/** Client-side validation before submitting. */
|
||||
function validate(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length < 2 || trimmed.length > 32) {
|
||||
return t('profile.validation.length')
|
||||
}
|
||||
if (!DISPLAY_NAME_PATTERN.test(trimmed)) {
|
||||
return t('profile.validation.pattern')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setErrorMessage('')
|
||||
|
||||
const trimmed = displayName.trim()
|
||||
const validationError = validate(trimmed)
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const result = await profileApi.updateProfile({ displayName: trimmed })
|
||||
|
||||
if (result.status === 'PENDING_REVIEW') {
|
||||
toast.success(t('profile.pendingReviewTitle'), t('profile.pendingReviewDescription'))
|
||||
} else {
|
||||
toast.success(t('profile.successTitle'), t('profile.successDescription'))
|
||||
// Refresh auth cache so the header and other components pick up the new name
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
}
|
||||
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setErrorMessage(
|
||||
truncateErrorMessage(error.message) ?? t('profile.defaultError'),
|
||||
)
|
||||
} else {
|
||||
setErrorMessage(t('profile.defaultError'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('profile.title')}</CardTitle>
|
||||
<CardDescription>{t('profile.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Avatar (read-only for now) */}
|
||||
{user?.avatarUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="h-16 w-16 rounded-2xl border-2 border-border/60 shadow-card"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Display name field */}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="display-name">
|
||||
{t('profile.displayName')}
|
||||
</label>
|
||||
|
||||
{isEditing ? (
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
maxLength={32}
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">{user?.displayName}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
|
||||
{t('profile.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('profile.saving') : t('profile.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{/* Email (read-only) */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('profile.email')}</label>
|
||||
<p className="text-sm text-muted-foreground">{user?.email || '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ interface User {
|
|||
displayName: string
|
||||
avatarUrl?: string
|
||||
platformRoles?: string[]
|
||||
oauthProvider?: string
|
||||
}
|
||||
|
||||
interface UserMenuProps {
|
||||
|
|
@ -29,6 +30,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
|
||||
const isLocalAccount = !user.oauthProvider
|
||||
const open = isHovered || isClickOpen
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
|
|
@ -174,9 +176,14 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
</Link>
|
||||
) : null}
|
||||
<div className="-mx-1 my-1 h-px bg-muted" />
|
||||
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.security')}
|
||||
<Link to="/settings/profile" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.profile')}
|
||||
</Link>
|
||||
{isLocalAccount ? (
|
||||
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.security')}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="-mx-1 my-1 h-px bg-muted" />
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue