mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #554 from iflytek/fix/auth-password-settings
fix(auth): restore password settings access
This commit is contained in:
commit
8413ee3950
22 changed files with 466 additions and 129 deletions
|
|
@ -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` 外层结构。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
|
|||
Optional<LocalCredential> findByUserId(String userId);
|
||||
|
||||
boolean existsByUsernameIgnoreCase(String username);
|
||||
|
||||
boolean existsByUserId(String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
68
web/e2e/settings-security-capability.spec.ts
Normal file
68
web/e2e/settings-security-capability.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
1
web/src/api/generated/schema.d.ts
vendored
1
web/src/api/generated/schema.d.ts
vendored
|
|
@ -3856,6 +3856,7 @@ export interface components {
|
|||
email?: string;
|
||||
avatarUrl?: string;
|
||||
oauthProvider?: string;
|
||||
canChangePassword?: boolean;
|
||||
platformRoles?: string[];
|
||||
};
|
||||
LocalRegisterRequest: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -759,6 +759,8 @@
|
|||
"successTitle": "密码修改成功",
|
||||
"successDescription": "请使用新密码重新登录。",
|
||||
"defaultError": "修改密码失败",
|
||||
"unavailableTitle": "此账号暂不可修改密码。",
|
||||
"unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。",
|
||||
"submitting": "提交中...",
|
||||
"submit": "更新密码"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
129
web/src/pages/settings/security.test.tsx
Normal file
129
web/src/pages/settings/security.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
108
web/src/shared/components/user-menu.test.tsx
Normal file
108
web/src/shared/components/user-menu.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue