Merge pull request #554 from iflytek/fix/auth-password-settings
Some checks failed
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled

fix(auth): restore password settings access
This commit is contained in:
dongmucat 2026-06-24 10:50:26 +08:00 committed by GitHub
commit 8413ee3950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 466 additions and 129 deletions

View file

@ -485,23 +485,20 @@ Session 中存储以下字段:
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"userId": "usr_42",
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
"oauthProvider": "local",
"canChangePassword": true,
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```
前端权限判定基于 `platformRoles` + `namespaces[].role`后端通过 `role_permission` 表查询权限码。
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`后端通过 `role_permission` 表查询权限码。
统一约束:
- `/api/v1/auth/me``/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
@ -56,6 +57,7 @@ public class AuthController extends BaseApiController {
private final UserRoleBindingRepository userRoleBindingRepository;
private final PlatformSessionService platformSessionService;
private final UserAccountRepository userAccountRepository;
private final AuthMeResponseAssembler authMeResponseAssembler;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
@ -64,7 +66,8 @@ public class AuthController extends BaseApiController {
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
@ -73,6 +76,7 @@ public class AuthController extends BaseApiController {
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
this.authMeResponseAssembler = authMeResponseAssembler;
}
/**
@ -111,7 +115,7 @@ public class AuthController extends BaseApiController {
freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
/**
@ -146,7 +150,7 @@ public class AuthController extends BaseApiController {
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
);
}
@ -178,7 +182,7 @@ public class AuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
authMeResponseAssembler.from(principal)
);
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController {
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService) {
PasswordResetService passwordResetService,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
}
@PostMapping("/register")
@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
return ok("response.success.created", authMeResponseAssembler.from(principal));
}
@PostMapping("/login")
@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
@PostMapping("/change-password")

View file

@ -10,15 +10,17 @@ public record AuthMeResponse(
String email,
String avatarUrl,
String oauthProvider,
boolean canChangePassword,
Set<String> platformRoles
) {
public static AuthMeResponse from(PlatformPrincipal principal) {
public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) {
return new AuthMeResponse(
principal.userId(),
principal.displayName(),
principal.email() != null ? principal.email() : "",
principal.avatarUrl() != null ? principal.avatarUrl() : "",
principal.oauthProvider(),
canChangePassword,
principal.platformRoles()
);
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AuthMeResponse;
import org.springframework.stereotype.Service;
/**
* Builds the current-user API response with account capabilities derived from
* authoritative backend state.
*/
@Service
public class AuthMeResponseAssembler {
private final LocalCredentialRepository localCredentialRepository;
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) {
this.localCredentialRepository = localCredentialRepository;
}
public AuthMeResponse from(PlatformPrincipal principal) {
return AuthMeResponse.from(
principal,
localCredentialRepository.existsByUserId(principal.userId())
);
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -64,6 +65,9 @@ class AuthControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/auth/me"))
@ -77,6 +81,7 @@ class AuthControllerTest {
given(userAccountRepository.findById("user-42"))
.willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png")));
given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("user-42")).willReturn(false);
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
@ -102,6 +107,7 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.userId").value("user-42"))
.andExpect(jsonPath("$.data.displayName").value("tester"))
.andExpect(jsonPath("$.data.oauthProvider").value("github"))
.andExpect(jsonPath("$.data.canChangePassword").value(false))
.andExpect(jsonPath("$.data.platformRoles[0]").value("USER"))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
@ -115,6 +121,7 @@ class AuthControllerTest {
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());
given(localCredentialRepository.existsByUserId("user-42")).willReturn(true);
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
@ -134,7 +141,8 @@ class AuthControllerTest {
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
.andExpect(jsonPath("$.data.displayName").value("UpdatedName")) // should return DB value
.andExpect(jsonPath("$.data.canChangePassword").value(true));
}
@Test

View file

@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
@ -52,6 +53,9 @@ class DirectAuthControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -67,6 +71,7 @@ class DirectAuthControllerTest {
given(userAccountRepository.findById("usr_direct_1"))
.willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null)));
given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("usr_direct_1")).willReturn(true);
MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login")
.with(csrf())
@ -77,6 +82,7 @@ class DirectAuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"))
.andExpect(jsonPath("$.data.canChangePassword").value(true))
.andReturn()
.getRequest()
.getSession(false);
@ -84,7 +90,8 @@ class DirectAuthControllerTest {
mockMvc.perform(get("/api/v1/auth/me").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"));
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
}
@Test

View file

@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -55,6 +56,9 @@ class LocalAuthControllerTest {
@MockBean
private PasswordResetService passwordResetService;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -66,6 +70,7 @@ class LocalAuthControllerTest {
Set.of("SUPER_ADMIN")
);
given(localAuthService.login("alice", "Abcd123!")).willReturn(principal);
given(localCredentialRepository.existsByUserId("usr_1")).willReturn(true);
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
@ -76,7 +81,8 @@ class LocalAuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_1"))
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
.andExpect(jsonPath("$.data.oauthProvider").value("local"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
verify(skillHubMetrics).recordLocalLogin(true);
verify(skillHubMetrics, never()).recordLocalLogin(false);
verify(authFailureThrottleService).resetIdentifier("local", "alice");
@ -93,6 +99,7 @@ class LocalAuthControllerTest {
Set.of()
);
given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal);
given(localCredentialRepository.existsByUserId("usr_2")).willReturn(true);
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
@ -102,7 +109,8 @@ class LocalAuthControllerTest {
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.displayName").value("bob"));
.andExpect(jsonPath("$.data.displayName").value("bob"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
verify(skillHubMetrics).incrementUserRegister();
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -48,12 +49,16 @@ class SessionBootstrapControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception {
given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of());
given(userAccountRepository.findById("sso-user-1"))
.willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null)));
given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false);
MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap")
.with(csrf())
@ -65,6 +70,7 @@ class SessionBootstrapControllerTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
.andExpect(jsonPath("$.data.displayName").value("Private SSO User"))
.andExpect(jsonPath("$.data.canChangePassword").value(false))
.andReturn()
.getRequest()
.getSession(false);
@ -73,7 +79,8 @@ class SessionBootstrapControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
.andExpect(jsonPath("$.data.oauthProvider").value("private-sso"));
.andExpect(jsonPath("$.data.oauthProvider").value("private-sso"))
.andExpect(jsonPath("$.data.canChangePassword").value(false));
}
@Test

View file

@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
boolean existsByUserId(String userId);
}

View file

@ -230,6 +230,20 @@ class LocalAuthServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
@Test
void changePassword_withoutLocalCredential_rejectsRequest() {
given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty());
assertThatThrownBy(() -> service.changePassword("oauth-only", "old", "Newpass123!"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.notEnabled")
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(passwordEncoder, never()).matches(any(), any());
verify(credentialRepository, never()).save(any(LocalCredential.class));
}
@Test
void register_rejectsInvalidEmailFormat() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);

View file

@ -1,11 +1,13 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { createFreshSession } from './helpers/session'
test.describe('Settings Pages (Real API)', () => {
test.use({ baseURL: 'http://127.0.0.1:3000' })
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
await createFreshSession(page, testInfo)
})
test('opens profile settings page', async ({ page }) => {

View file

@ -0,0 +1,68 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { csrfHeaders } from './helpers/csrf'
import { loginWithCredentials } from './helpers/session'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function adminCredentials() {
return {
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
}
}
async function currentDisplayName(page: Page, headers?: Record<string, string>): Promise<string> {
const response = await page.context().request.get('/api/v1/auth/me', { headers })
expect(response.ok()).toBeTruthy()
const body = await response.json() as { data: { displayName: string } }
return body.data.displayName
}
test.describe('Security Settings capability (Real API)', () => {
test.use({ baseURL: 'http://127.0.0.1:3000' })
test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => {
await setEnglishLocale(page)
await loginWithCredentials(page, adminCredentials(), testInfo)
const displayName = await currentDisplayName(page)
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
await expect(page.getByLabel('Current Password')).toBeVisible()
await expect(page.getByLabel('New Password')).toBeVisible()
await page.getByRole('button', { name: displayName }).click()
await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible()
})
test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-user',
})
const displayName = await currentDisplayName(page, { 'X-Mock-User-Id': 'local-user' })
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
await expect(page.getByText('Password changes are unavailable for this account.')).toBeVisible()
await expect(page.getByLabel('Current Password')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0)
await page.getByRole('button', { name: displayName }).click()
await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0)
const response = await page.context().request.post('/api/v1/auth/local/change-password', {
data: {
currentPassword: 'Passw0rd!123',
newPassword: 'N3wPassw0rd!123',
},
headers: await csrfHeaders(page, { 'X-Mock-User-Id': 'local-user' }),
})
expect(response.status()).toBe(400)
})
})

View file

@ -3856,6 +3856,7 @@ export interface components {
email?: string;
avatarUrl?: string;
oauthProvider?: string;
canChangePassword?: boolean;
platformRoles?: string[];
};
LocalRegisterRequest: {

View file

@ -759,6 +759,8 @@
"successTitle": "Password changed successfully",
"successDescription": "Please sign in again with your new password.",
"defaultError": "Failed to change password",
"unavailableTitle": "Password changes are unavailable for this account.",
"unavailableDescription": "This account signs in through an external identity provider or has no local password credential.",
"submitting": "Submitting...",
"submit": "Update Password"
},

View file

@ -759,6 +759,8 @@
"successTitle": "密码修改成功",
"successDescription": "请使用新密码重新登录。",
"defaultError": "修改密码失败",
"unavailableTitle": "此账号暂不可修改密码。",
"unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。",
"submitting": "提交中...",
"submit": "更新密码"
},

View file

@ -1,61 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/api/client', () => ({
ApiError: class ApiError extends Error {
status?: number
},
authApi: {
changePassword: vi.fn(),
logout: vi.fn(),
},
}))
vi.mock('@/shared/lib/error-display', () => ({
truncateErrorMessage: (v: string) => v,
}))
vi.mock('@/shared/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
CardDescription: ({ children }: { children: unknown }) => children,
CardHeader: ({ children }: { children: unknown }) => children,
CardTitle: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/input', () => ({
Input: () => null,
}))
import { SecuritySettingsPage } from './security'
describe('SecuritySettingsPage', () => {
it('exports a named component function', () => {
expect(typeof SecuritySettingsPage).toBe('function')
})
})

View file

@ -0,0 +1,129 @@
import type { InputHTMLAttributes, ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useAuthMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/api/client', () => ({
ApiError: class ApiError extends Error {
status?: number
},
authApi: {
changePassword: vi.fn(),
logout: vi.fn(),
},
}))
vi.mock('@/features/auth/use-auth', () => ({
useAuth: useAuthMock,
}))
vi.mock('@/shared/lib/error-display', () => ({
truncateErrorMessage: (v: string) => v,
}))
vi.mock('@/shared/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({
children,
disabled,
type,
}: {
children: ReactNode
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: ReactNode }) => children,
CardContent: ({ children }: { children: ReactNode }) => children,
CardDescription: ({ children }: { children: ReactNode }) => children,
CardHeader: ({ children }: { children: ReactNode }) => children,
CardTitle: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/shared/ui/input', () => ({
Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}))
import { SecuritySettingsPage } from './security'
beforeEach(() => {
useAuthMock.mockReturnValue({
user: {
userId: 'user-1',
displayName: 'Local User',
platformRoles: ['USER'],
canChangePassword: true,
},
})
})
describe('SecuritySettingsPage', () => {
it('exports a named component function', () => {
expect(typeof SecuritySettingsPage).toBe('function')
})
it('renders the password form when password changes are allowed', () => {
const html = renderToStaticMarkup(<SecuritySettingsPage />)
expect(html).toContain('security.currentPassword')
expect(html).toContain('security.newPassword')
expect(html).toContain('security.submit')
})
it('renders a read-only unavailable state when password changes are not allowed', () => {
useAuthMock.mockReturnValue({
user: {
userId: 'oauth-user',
displayName: 'OAuth User',
oauthProvider: 'github',
platformRoles: ['USER'],
canChangePassword: false,
},
})
const html = renderToStaticMarkup(<SecuritySettingsPage />)
expect(html).toContain('security.unavailableTitle')
expect(html).toContain('security.unavailableDescription')
expect(html).not.toContain('security.currentPassword')
expect(html).not.toContain('security.submit')
})
it('defaults to the unavailable state while the user capability is unknown', () => {
useAuthMock.mockReturnValue({ user: null })
const html = renderToStaticMarkup(<SecuritySettingsPage />)
expect(html).toContain('security.unavailableTitle')
expect(html).not.toContain('security.currentPassword')
expect(html).not.toContain('security.submit')
})
})

View file

@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiError, authApi } from '@/api/client'
import { useAuth } from '@/features/auth/use-auth'
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
import { truncateErrorMessage } from '@/shared/lib/error-display'
import { toast } from '@/shared/lib/toast'
@ -10,6 +11,14 @@ import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
interface PasswordChangeCapabilityUser {
canChangePassword?: boolean
}
function canUsePasswordChangeForm(user?: PasswordChangeCapabilityUser | null) {
return user?.canChangePassword === true
}
/**
* Security settings page for password changes. After a successful change the
* user is logged out so all existing authenticated state is re-established with
@ -19,10 +28,12 @@ export function SecuritySettingsPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user } = useAuth()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const canChangePassword = canUsePasswordChangeForm(user)
/**
* Submits the password change request and clears local auth state afterward,
@ -32,6 +43,11 @@ export function SecuritySettingsPage() {
event.preventDefault()
setErrorMessage('')
if (!canChangePassword) {
setErrorMessage(t('security.unavailableTitle'))
return
}
if (!currentPassword.trim()) {
setErrorMessage(t('security.currentPasswordRequired'))
return
@ -78,32 +94,39 @@ export function SecuritySettingsPage() {
<CardDescription>{t('security.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="current-password">{t('security.currentPassword')}</label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
{canChangePassword ? (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="current-password">{t('security.currentPassword')}</label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-password">{t('security.newPassword')}</label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('security.submitting') : t('security.submit')}
</Button>
</form>
) : (
<div className="rounded-lg border border-border/70 bg-muted/30 p-4">
<p className="text-sm font-medium text-foreground">{t('security.unavailableTitle')}</p>
<p className="mt-2 text-sm text-muted-foreground">{t('security.unavailableDescription')}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-password">{t('security.newPassword')}</label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('security.submitting') : t('security.submit')}
</Button>
</form>
)}
</CardContent>
</Card>
</div>

View file

@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import * as mod from './user-menu'
/**
* UserMenu is a React component that renders a hover/click dropdown menu with
* role-based navigation links (dashboard, reviews, admin, etc.) and logout.
* Internal helpers (hasRole, closeMenu, handleMouseEnter/Leave) and the
* menuItemClassName constant are scoped inside the component function.
* There are no exported pure helpers or constants to test here.
*
* We verify the module shape so downstream consumers break fast
* if the export contract changes.
*/
describe('user-menu module exports', () => {
it('exports the UserMenu component', () => {
expect(mod.UserMenu).toBeTypeOf('function')
})
})

View file

@ -0,0 +1,108 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import * as mod from './user-menu'
import { UserMenu } from './user-menu'
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useState: (initialValue: unknown) => [
typeof initialValue === 'boolean' ? true : initialValue,
vi.fn(),
],
}
})
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@tanstack/react-router', () => ({
Link: ({
children,
className,
onClick,
to,
}: {
children: ReactNode
className?: string
onClick?: () => void
to: string
}) => (
<a
href={to}
className={className}
onClick={(event) => {
event.preventDefault()
onClick?.()
}}
>
{children}
</a>
),
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
setQueryData: vi.fn(),
}),
}))
vi.mock('@/api/client', () => ({
authApi: {
logout: vi.fn(),
},
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [] }),
}))
/**
* UserMenu is a React component that renders a hover/click dropdown menu with
* role-based navigation links (dashboard, reviews, admin, etc.) and logout.
*/
describe('user-menu module exports', () => {
it('exports the UserMenu component', () => {
expect(mod.UserMenu).toBeTypeOf('function')
})
})
describe('UserMenu security settings visibility', () => {
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'OAuth Linked User',
oauthProvider: 'github',
platformRoles: ['USER'],
canChangePassword: true,
}}
/>,
)
expect(html).toContain('user.menu.security')
})
it('hides security settings when password changes are not allowed, even for a local-looking account', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'Local User',
platformRoles: ['USER'],
canChangePassword: false,
}}
/>,
)
expect(html).not.toContain('user.menu.security')
})
})

View file

@ -14,6 +14,7 @@ interface User {
avatarUrl?: string
platformRoles?: string[]
oauthProvider?: string
canChangePassword?: boolean
}
interface UserMenuProps {
@ -37,7 +38,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
const isSuperAdmin = hasRole('SUPER_ADMIN')
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
const isLocalAccount = !user.oauthProvider
const canChangePassword = user.canChangePassword === true
const open = isHovered || isClickOpen
const clearCloseTimer = () => {
@ -200,7 +201,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
<Link to="/settings/notifications" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.notifications')}
</Link>
{isLocalAccount ? (
{canChangePassword ? (
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.security')}
</Link>