diff --git a/.gitignore b/.gitignore
index c3d2dc4b..3d73f1ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,9 +68,8 @@ __pycache__/
# Superpowers (AI planning artifacts)
docs/superpowers/
docs/review/
+docs/requirements/
CLAUDE.md
# oh-my-claudecode
.omc
-
-
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java
index b85d9caa..38f33ec6 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java
@@ -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);
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java
new file mode 100644
index 00000000..f678a8e7
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ProfileModerationProperties.java
@@ -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.
+ *
+ *
Controls whether machine review and/or human review are enabled
+ * when users update their profile. Both default to {@code false} (open-source mode).
+ *
+ *
Configuration combinations:
+ *
+ * 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
+ *
+ *
+ * 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;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java
index 688f9fec..76b62a92 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java
@@ -84,10 +84,20 @@ public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal
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));
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
new file mode 100644
index 00000000..cdbb5122
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
@@ -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.
+ *
+ * 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_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 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.
+ *
+ * Depending on moderation configuration, changes may be applied
+ * immediately or queued for human review.
+ */
+ @PatchMapping
+ public ApiResponse 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 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 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 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");
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java
new file mode 100644
index 00000000..448bd8e6
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PendingChangesResponse.java
@@ -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 changes,
+ Instant createdAt
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java
new file mode 100644
index 00000000..f4ff47a2
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ProfileUpdateStatus.java
@@ -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
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java
new file mode 100644
index 00000000..3f264b02
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileRequest.java
@@ -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.
+ *
+ * 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).
+ *
+ *
Validation rules for displayName:
+ *
+ * Length: 2–32 characters (after trim)
+ * Allowed characters: Chinese, English, digits, underscore, hyphen
+ *
+ *
+ * @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;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java
new file mode 100644
index 00000000..b8d5005f
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UpdateProfileResponse.java
@@ -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
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java
new file mode 100644
index 00000000..8f8f53f8
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/UserProfileResponse.java
@@ -0,0 +1,19 @@
+package com.iflytek.skillhub.dto;
+
+/**
+ * Response DTO for GET /api/v1/user/profile.
+ *
+ * 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
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java
new file mode 100644
index 00000000..d6023e4e
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NoOpProfileModerationService.java
@@ -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.
+ *
+ *
Always returns {@link ModerationResult#approved()}, meaning profile
+ * changes take effect immediately without any review.
+ *
+ *
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 changes) {
+ return ModerationResult.approved();
+ }
+}
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 03c6fcaa..2b32c5e4 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -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:
diff --git a/server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql b/server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql
new file mode 100644
index 00000000..f1e395c9
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/db/migration/V27__profile_change_request.sql
@@ -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);
diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties
index 6d099224..7467829e 100644
--- a/server/skillhub-app/src/main/resources/messages.properties
+++ b/server/skillhub-app/src/main/resources/messages.properties
@@ -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
+
diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties
index b98d4ad5..5075d0ae 100644
--- a/server/skillhub-app/src/main/resources/messages_zh.properties
+++ b/server/skillhub-app/src/main/resources/messages_zh.properties
@@ -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=资料变更已提交审核
+
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
index 29184539..47e77a31 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java
@@ -107,6 +107,36 @@ void meShouldReturnCurrentPrincipal() throws Exception {
.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"))
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java
new file mode 100644
index 00000000..cf4202af
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerTest.java
@@ -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\":\"\"}"))
+ .andExpect(status().isBadRequest());
+ }
+}
+
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java
new file mode 100644
index 00000000..3b933c4f
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationDecision.java
@@ -0,0 +1,16 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Outcome of a profile moderation check.
+ *
+ * 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
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java
new file mode 100644
index 00000000..e777f46a
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ModerationResult.java
@@ -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);
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java
new file mode 100644
index 00000000..67ba2530
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequest.java
@@ -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.
+ *
+ *
Each request captures a batch of field changes as JSONB (e.g. displayName, avatarUrl),
+ * along with the previous values for audit and rollback purposes.
+ *
+ *
Depending on the moderation configuration, a request may be:
+ *
+ * Immediately approved (no moderation)
+ * Rejected by machine review
+ * Queued for human review (PENDING)
+ *
+ *
+ * @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; }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java
new file mode 100644
index 00000000..7e4d10dd
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeRequestRepository.java
@@ -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 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 findByUserIdAndStatus(String userId, ProfileChangeStatus status);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java
new file mode 100644
index 00000000..fb59f202
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileChangeStatus.java
@@ -0,0 +1,25 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Status of a profile change request throughout its lifecycle.
+ *
+ * State transitions:
+ *
+ * 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)
+ *
+ */
+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
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java
new file mode 100644
index 00000000..4b3b856d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationConfig.java
@@ -0,0 +1,17 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Domain-level abstraction for profile moderation configuration.
+ *
+ * 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();
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java
new file mode 100644
index 00000000..ca5c720f
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileModerationService.java
@@ -0,0 +1,22 @@
+package com.iflytek.skillhub.domain.user;
+
+import java.util.Map;
+
+/**
+ * Pluggable moderation service for user profile changes.
+ *
+ *
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 changes);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java
new file mode 100644
index 00000000..c3bfc4af
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UpdateProfileResult.java
@@ -0,0 +1,28 @@
+package com.iflytek.skillhub.domain.user;
+
+/**
+ * Result of a profile update operation.
+ *
+ * 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();
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java
new file mode 100644
index 00000000..d6259c4b
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java
@@ -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.
+ *
+ *
Orchestrates the full update flow: validation → machine review →
+ * human review (if configured) → apply changes → audit logging.
+ *
+ *
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).
+ *
+ *
Depending on moderation config, this may:
+ *
+ * Apply changes immediately (no moderation)
+ * Reject via machine review
+ * Queue for human review (PENDING)
+ *
+ *
+ * @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 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 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 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 buildOldValues(UserAccount user, Map changes) {
+ Map 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 changes,
+ Map 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);
+ }
+ }
+}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java
new file mode 100644
index 00000000..2990f86c
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java
@@ -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 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"));
+ }
+}
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java
new file mode 100644
index 00000000..cc8c48ae
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ProfileChangeRequestJpaRepository.java
@@ -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, ProfileChangeRequestRepository {
+
+ @Override
+ List findByUserIdAndStatus(String userId, ProfileChangeStatus status);
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 25e5dc35..c861ea1d 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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()
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index f70f0e1b..b6942b4a 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -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,
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 4af7d31e..78eda345 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -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.",
@@ -891,6 +910,7 @@
"users": "User Management",
"auditLog": "Audit Log",
"security": "Security Settings",
+ "profile": "Profile Settings",
"accounts": "Account Merge",
"logout": "Logout"
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 67d765f2..d12e9ff7 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -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": "已启用本地账号密码登录时,可以在这里更新密码。",
@@ -891,6 +910,7 @@
"users": "用户管理",
"auditLog": "审计日志",
"security": "安全设置",
+ "profile": "个人设置",
"accounts": "账号合并",
"logout": "退出登录"
}
diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx
new file mode 100644
index 00000000..ce9327ff
--- /dev/null
+++ b/web/src/pages/settings/profile.tsx
@@ -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) {
+ 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 (
+
+
+
+ {t('profile.title')}
+ {t('profile.subtitle')}
+
+
+ {/* Avatar (read-only for now) */}
+ {user?.avatarUrl ? (
+
+
+
+ ) : null}
+
+ {/* Display name field */}
+
+
+ {/* Email (read-only) */}
+
+
{t('profile.email')}
+
{user?.email || '-'}
+
+
+
+
+ )
+}
diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx
index 5234e9fa..ace1df84 100644
--- a/web/src/shared/components/user-menu.tsx
+++ b/web/src/shared/components/user-menu.tsx
@@ -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) {
) : null}
-
- {t('user.menu.security')}
+
+ {t('user.menu.profile')}
+ {isLocalAccount ? (
+
+ {t('user.menu.security')}
+
+ ) : null}