Commit 7e722efd by DaiJiezhang

Merge branch 'codex/master-auth-login-fixes' into 'master'

feat: 合并认证权限与原生登录密码框修复

See merge request !5
parents 1625ba48 94eeb8f5
...@@ -22,7 +22,7 @@ public class SystemUserAdminService { ...@@ -22,7 +22,7 @@ public class SystemUserAdminService {
/** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */ /** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */
public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); } public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); }
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */ /** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */
public SystemUserResponse createUser(SystemUserCreateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); if (users.selectCount(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)) > 0) throw new IllegalArgumentException("用户名已存在"); SystemUserEntity user = new SystemUserEntity(); user.setUsername(request.username()); user.setRoleCode(request.roleCode()); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setDeleteTime(0L); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setAuthVersion(1); if ("SUPER_ADMIN".equals(actor)) { user.setStatus("DISABLED"); user.setPasswordHash(null); } else { requireValidPassword(request.password(), request.username()); user.setStatus("ACTIVE"); user.setPasswordHash(encoder.encode(request.password())); } users.insert(user); return responseOf(user); } public SystemUserResponse createUser(SystemUserCreateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); if (users.selectCount(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)) > 0) throw new IllegalArgumentException("用户名已存在"); SystemUserEntity user = new SystemUserEntity(); user.setUsername(request.username()); user.setRoleCode(request.roleCode()); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setDeleteTime(0L); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setAuthVersion(1); if ("SUPER_ADMIN".equals(actor)) { user.setStatus("DISABLED"); user.setPasswordHash(null); } else { requireValidPassword(request.password()); user.setStatus("ACTIVE"); user.setPasswordHash(encoder.encode(request.password())); } users.insert(user); return responseOf(user); }
/** 代码作用(白话):更新非开发者账号的角色、启停和逐页权限,禁止绕过 Jeddy;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):编辑面板 -> 角色边界 -> 用户表更新 -> 下次请求按新权限判定。 */ /** 代码作用(白话):更新非开发者账号的角色、启停和逐页权限,禁止绕过 Jeddy;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):编辑面板 -> 角色边界 -> 用户表更新 -> 下次请求按新权限判定。 */
public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); validateRequestedStatus(request.status(), user); String normalizedPermissions = permissions.validatePermissions(request.pagePermissions()); boolean authorizationChanged = authorizationStateChanged(user, request, normalizedPermissions); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); user.setPagePermissions(normalizedPermissions); if (authorizationChanged) user.setAuthVersion(nextAuthVersion(user.getAuthVersion())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); } public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); validateRequestedStatus(request.status(), user); String normalizedPermissions = permissions.validatePermissions(request.pagePermissions()); boolean authorizationChanged = authorizationStateChanged(user, request, normalizedPermissions); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); user.setPagePermissions(normalizedPermissions); if (authorizationChanged) user.setAuthVersion(nextAuthVersion(user.getAuthVersion())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); }
/** Plain purpose: decide whether an account edit changes authorization facts embedded in its existing login token. Related files: PagePermissionService.java, AuthTokenFilter.java. Flow: saved account/request -> normalized authorization comparison -> auth-version increment or session retention. */ /** Plain purpose: decide whether an account edit changes authorization facts embedded in its existing login token. Related files: PagePermissionService.java, AuthTokenFilter.java. Flow: saved account/request -> normalized authorization comparison -> auth-version increment or session retention. */
...@@ -32,7 +32,7 @@ public class SystemUserAdminService { ...@@ -32,7 +32,7 @@ public class SystemUserAdminService {
/** Plain purpose: reject invalid status transitions before changing an account entity. Related files: SystemUserUpdateRequest.java, SystemUserAdminService.java. Flow: update request -> status validation -> safe entity update or readable failure. */ /** Plain purpose: reject invalid status transitions before changing an account entity. Related files: SystemUserUpdateRequest.java, SystemUserAdminService.java. Flow: update request -> status validation -> safe entity update or readable failure. */
private void validateRequestedStatus(String status, SystemUserEntity user) { if (!("ACTIVE".equals(status) || "DISABLED".equals(status))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(status) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); } private void validateRequestedStatus(String status, SystemUserEntity user) { if (!("ACTIVE".equals(status) || "DISABLED".equals(status))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(status) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); }
/** 代码作用(白话):仅允许开发者为非开发者账号设置密码,并交给数据库触发器记录时间及失效旧会话;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> BCrypt hash -> UPDATE password_hash -> trigger(auth_version)。 */ /** 代码作用(白话):仅允许开发者为非开发者账号设置密码,并交给数据库触发器记录时间及失效旧会话;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> BCrypt hash -> UPDATE password_hash -> trigger(auth_version)。 */
public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password(), user.getUsername()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); } public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); }
/** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */ /** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */
private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); } private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */ /** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */
...@@ -41,8 +41,8 @@ public class SystemUserAdminService { ...@@ -41,8 +41,8 @@ public class SystemUserAdminService {
private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); } private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */ /** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */
private SystemUserEntity findManageableUser(Long id) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getId, id).ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null) throw new IllegalArgumentException("账号不存在"); return user; } private SystemUserEntity findManageableUser(Long id) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getId, id).ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null) throw new IllegalArgumentException("账号不存在"); return user; }
/** 代码作用(白话):验证密码长度和用户名相似度,避免弱密码直接进入数据库;关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 规则校验 -> BCrypt 哈希。 */ /** 代码作用(白话):验证用户确认的密码长度;用户名可以出现在密码中,规则通过后才生成 BCrypt 哈希。关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 12-72 位校验 -> BCrypt 哈希。 */
private void requireValidPassword(String password, String username) { if (password == null || password.length() < 12 || password.length() > 72 || password.toLowerCase().contains(username.toLowerCase())) throw new IllegalArgumentException("密码需为 12-72 位且不能包含用户名"); } private void requireValidPassword(String password) { if (password == null || password.length() < 12 || password.length() > 72) throw new IllegalArgumentException("密码需为 12-72 位"); }
/** 代码作用(白话):将实体转为安全响应,并统一展开固定管理员的有效权限;关联文件:SystemUserResponse.java、PagePermissionService.java。关联逻辑(调用链/数据流):实体 -> 权限计算 -> 前端列表。 */ /** 代码作用(白话):将实体转为安全响应,并统一展开固定管理员的有效权限;关联文件:SystemUserResponse.java、PagePermissionService.java。关联逻辑(调用链/数据流):实体 -> 权限计算 -> 前端列表。 */
private SystemUserResponse responseOf(SystemUserEntity user) { return new SystemUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), user.getStatus(), permissions.effectivePermissions(user), user.getPasswordUpdatedAt()); } private SystemUserResponse responseOf(SystemUserEntity user) { return new SystemUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), user.getStatus(), permissions.effectivePermissions(user), user.getPasswordUpdatedAt()); }
} }
package com.xyw.console.config; package com.xyw.console.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.xyw.console.auth.AuthTokenFilter; import com.xyw.console.common.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import com.xyw.console.auth.AuthTokenFilter; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfException;
@Configuration @EnableWebSecurity @Configuration @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
/** 代码作用(白话):配置无状态认证、CSRF 和统一 401/403 响应;关联文件:AuthTokenFilter.java、WebConfig.java。关联逻辑(调用链/数据流):浏览器请求 -> Security Filter Chain -> Cookie 验签 -> Controller。 */ /** 代码作用(白话):配置无状态认证、CSRF 和统一 401/403 响应;关联文件:AuthTokenFilter.java、WebConfig.java。关联逻辑(调用链/数据流):浏览器请求 -> Security Filter Chain -> Cookie 验签 -> Controller。 */
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,accessDeniedMessage(error)))).build(); } @Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(csrfRequestHandler()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,accessDeniedMessage(error)))).build(); }
/** 代码作用(白话):指定单页应用使用的 CSRF 请求值读取方式,让前端从 XSRF-TOKEN Cookie 读取的原始值可由 X-XSRF-TOKEN 请求头提交。关联文件:auth-api-client.js、SecurityConfigTest.java。关联逻辑(调用链/数据流):CSRF Cookie -> 前端请求头 -> 此处理器 -> Spring Security 放行合法写请求。 */
CsrfTokenRequestAttributeHandler csrfRequestHandler() { return new CsrfTokenRequestAttributeHandler(); }
/** Plain purpose: choose a safe client action for CSRF failures without exposing account or token details. Related files: auth-api-client.js, SecurityConfigTest.java. Flow: Spring access denial -> safe message -> JSON response -> browser refresh/retry guidance. */ /** Plain purpose: choose a safe client action for CSRF failures without exposing account or token details. Related files: auth-api-client.js, SecurityConfigTest.java. Flow: Spring access denial -> safe message -> JSON response -> browser refresh/retry guidance. */
String accessDeniedMessage(Exception error) { return error instanceof CsrfException ? "安全校验已失效,请刷新页面后重试" : "没有权限"; } String accessDeniedMessage(Exception error) { return error instanceof CsrfException ? "安全校验已失效,请刷新页面后重试" : "没有权限"; }
/** 代码作用(白话):生成 BCrypt 编码器,确保密码只存不可逆哈希;关联文件:AuthService.java。关联逻辑(调用链/数据流):开发者设密码/登录验证 -> BCrypt。 */ /** 代码作用(白话):生成 BCrypt 编码器,确保密码只存不可逆哈希;关联文件:AuthService.java。关联逻辑(调用链/数据流):开发者设密码/登录验证 -> BCrypt。 */
......
...@@ -80,6 +80,16 @@ class SystemUserAdminServiceTest { ...@@ -80,6 +80,16 @@ class SystemUserAdminServiceTest {
verify(users).insert(any(SystemUserEntity.class)); verify(users).insert(any(SystemUserEntity.class));
} }
/** Plain purpose: allow the user-approved username-containing password policy while retaining the existing password length boundary. Related files: SystemUserAdminService.java, SystemUserCreateRequest.java. Flow: developer create request -> password validation -> BCrypt encoder -> account insert. */
@Test void developerCanCreateAccountWhenPasswordEqualsUsernameAndContainsJeddy() {
when(users.selectCount(any())).thenReturn(0L);
PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(any())).thenReturn("hash");
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder);
service.createUser(new SystemUserCreateRequest("Jeddy2026User", "OPERATIONS", "Jeddy2026User", Map.of()));
verify(users).insert(any(SystemUserEntity.class));
}
/** Plain purpose: prove that an ordinary role cannot manage accounts even if it calls the service directly. Related files: SystemUserAdminService.java, SystemUserAdminController.java. Flow: finance principal -> role boundary -> access denial -> no mapper write. */ /** Plain purpose: prove that an ordinary role cannot manage accounts even if it calls the service directly. Related files: SystemUserAdminService.java, SystemUserAdminController.java. Flow: finance principal -> role boundary -> access denial -> no mapper write. */
@Test void nonAdministratorCannotCreateAccounts() { @Test void nonAdministratorCannotCreateAccounts() {
authenticate("FINANCE"); authenticate("FINANCE");
......
...@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; ...@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.csrf.DefaultCsrfToken; import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException; import org.springframework.security.web.csrf.InvalidCsrfTokenException;
...@@ -21,4 +22,14 @@ class SecurityConfigTest { ...@@ -21,4 +22,14 @@ class SecurityConfigTest {
SecurityConfig config = new SecurityConfig(); SecurityConfig config = new SecurityConfig();
assertEquals("没有权限", config.accessDeniedMessage(new AccessDeniedException("internal detail"))); assertEquals("没有权限", config.accessDeniedMessage(new AccessDeniedException("internal detail")));
} }
/** 代码作用(白话):验证单页应用把可读的 CSRF Cookie 值写进请求头后,安全配置会按原始值校验,而不会把合法退出请求误判为伪造请求。关联文件:SecurityConfig.java、auth-api-client.js。关联逻辑(调用链/数据流):XSRF-TOKEN Cookie -> X-XSRF-TOKEN 请求头 -> Spring Security CSRF 校验 -> /api/auth/logout。 */
@Test void csrfRequestHandlerAcceptsRawCookieTokenFromSpaHeader() {
String tokenValue = "csrf-cookie-token";
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X-XSRF-TOKEN", tokenValue);
DefaultCsrfToken csrfToken = new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", tokenValue);
assertEquals(tokenValue, new SecurityConfig().csrfRequestHandler().resolveCsrfTokenValue(request, csrfToken));
}
} }
...@@ -35,7 +35,7 @@ export default { ...@@ -35,7 +35,7 @@ export default {
} }
} }
/** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */ /** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 清除密码/账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */
async function submit() { async function submit() {
submitting.value = true; submitting.value = true;
try { try {
......
...@@ -21,4 +21,4 @@ export default { setup() { const users = ref([]); const loading = ref(false); co ...@@ -21,4 +21,4 @@ export default { setup() { const users = ref([]); const loading = ref(false); co
function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; } function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; }
/** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */ /** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */
async function savePassword() { try { await resetSystemUserPassword(target.value.id, password.value); password.value = ''; passwordOpen.value = false; ElMessage.success('密码已更新,旧会话已失效'); await load(); } catch (error) { ElMessage.error(error.message || '密码更新失败'); } } async function savePassword() { try { await resetSystemUserPassword(target.value.id, password.value); password.value = ''; passwordOpen.value = false; ElMessage.success('密码已更新,旧会话已失效'); await load(); } catch (error) { ElMessage.error(error.message || '密码更新失败'); } }
load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位,且不能包含用户名。</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` }; load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` };
...@@ -14,7 +14,7 @@ body { margin: 0; } ...@@ -14,7 +14,7 @@ body { margin: 0; }
.login-brand__mark span { width: 4px; border-radius: 999px; background: rgba(255, 255, 255, .92); box-shadow: 0 0 8px rgba(187, 218, 255, .72); }.login-brand__mark span:nth-child(1) { height: 15px; }.login-brand__mark span:nth-child(2) { height: 25px; }.login-brand__mark span:nth-child(3) { height: 19px; } .login-brand__mark span { width: 4px; border-radius: 999px; background: rgba(255, 255, 255, .92); box-shadow: 0 0 8px rgba(187, 218, 255, .72); }.login-brand__mark span:nth-child(1) { height: 15px; }.login-brand__mark span:nth-child(2) { height: 25px; }.login-brand__mark span:nth-child(3) { height: 19px; }
.login-brand__eyebrow { margin: 0 0 5px; color: rgba(211, 226, 255, .66); font-size: 10px; font-weight: 600; letter-spacing: .16em; }.login-brand h1 { margin: 0; color: var(--login-text-primary); font-size: clamp(22px, 2vw, 28px); font-weight: 700; line-height: 1.25; letter-spacing: .04em; text-shadow: 0 4px 18px rgba(5, 12, 28, .25); white-space: nowrap; } .login-brand__eyebrow { margin: 0 0 5px; color: rgba(211, 226, 255, .66); font-size: 10px; font-weight: 600; letter-spacing: .16em; }.login-brand h1 { margin: 0; color: var(--login-text-primary); font-size: clamp(22px, 2vw, 28px); font-weight: 700; line-height: 1.25; letter-spacing: .04em; text-shadow: 0 4px 18px rgba(5, 12, 28, .25); white-space: nowrap; }
.login-panel__intro { margin: 0 0 26px; color: var(--login-text-secondary); font-size: 14px; text-align: center; } .login-panel__intro { margin: 0 0 26px; color: var(--login-text-secondary); font-size: 14px; text-align: center; }
.login-form__item.el-form-item { margin-bottom: 16px; }.login-form__input .el-input__wrapper { min-height: 60px; padding: 0 18px; border: 1px solid var(--login-field-border); border-radius: 18px; background: linear-gradient(160deg, rgba(255, 255, 255, .012), rgba(255, 255, 255, .003)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035), inset 0 -1px 0 rgba(255, 255, 255, .008), 0 6px 18px rgba(5, 10, 22, .025); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: border-color .2s ease, box-shadow .2s ease, background .2s ease, transform .2s ease; }.login-form__input .el-input__wrapper:hover { border-color: rgba(211, 225, 255, .13); }.login-form__input.is-focus .el-input__wrapper { border-color: var(--login-field-focus); background: linear-gradient(160deg, rgba(255, 255, 255, .025), rgba(255, 255, 255, .007)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055), 0 0 0 4px rgba(136, 182, 255, .055), 0 10px 24px rgba(4, 11, 24, .055); transform: translateY(-1px); }.login-form__input .el-input__inner { color: var(--login-text-primary); font-size: 17px; }.login-form__input .el-input__inner::placeholder { color: rgba(224, 231, 255, .58); }.login-form__input .el-input__password { color: rgba(229, 236, 255, .74); }.login-form__input .el-input__password:hover { color: rgba(255, 255, 255, .96); } .login-form__item.el-form-item { margin-bottom: 16px; }.login-form__input .el-input__wrapper { min-height: 60px; padding: 0 18px; border: 1px solid var(--login-field-border); border-radius: 18px; background: linear-gradient(160deg, rgba(255, 255, 255, .012), rgba(255, 255, 255, .003)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035), inset 0 -1px 0 rgba(255, 255, 255, .008), 0 6px 18px rgba(5, 10, 22, .025); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; }.login-form__input .el-input__wrapper:hover { border-color: rgba(211, 225, 255, .13); }.login-form__input .el-input__wrapper.is-focus { border-color: var(--login-field-focus); background: linear-gradient(160deg, rgba(255, 255, 255, .025), rgba(255, 255, 255, .007)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055), 0 0 0 4px rgba(136, 182, 255, .055), 0 10px 24px rgba(4, 11, 24, .055); }.login-form__input .el-input__inner { color: var(--login-text-primary); }.login-form__input .el-input__inner::placeholder { color: rgba(224, 231, 255, .58); }.login-form__input .el-input__password { color: rgba(229, 236, 255, .74); }.login-form__input .el-input__password:hover { color: rgba(255, 255, 255, .96); }
.login-submit.el-button { width: 100%; height: 60px; margin-top: 8px; border: 1px solid rgba(205, 224, 255, .18); border-radius: 18px; background: linear-gradient(135deg, rgba(194, 223, 255, .46) 0%, rgba(120, 168, 255, .46) 40%, rgba(84, 127, 255, .50) 100%); color: #fff; font-size: 17px; font-weight: 700; letter-spacing: .08em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 18px 30px rgba(60, 103, 207, .18), 0 4px 10px rgba(4, 9, 18, .16); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); transition: transform .2s ease, box-shadow .2s ease, opacity .2s ease, background .2s ease; }.login-submit.el-button:hover { transform: translateY(-1px); background: linear-gradient(135deg, rgba(194, 223, 255, .56), rgba(120, 168, 255, .56) 40%, rgba(84, 127, 255, .60)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .42), 0 22px 36px rgba(60, 103, 207, .22), 0 6px 14px rgba(4, 9, 18, .22); }.login-submit.el-button:active { transform: translateY(1px) scale(.99); }.login-submit.el-button.is-loading, .login-submit.el-button.is-disabled { opacity: .76; transform: none; } .login-submit.el-button { width: 100%; height: 60px; margin-top: 8px; border: 1px solid rgba(205, 224, 255, .18); border-radius: 18px; background: linear-gradient(135deg, rgba(194, 223, 255, .46) 0%, rgba(120, 168, 255, .46) 40%, rgba(84, 127, 255, .50) 100%); color: #fff; font-size: 17px; font-weight: 700; letter-spacing: .08em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 18px 30px rgba(60, 103, 207, .18), 0 4px 10px rgba(4, 9, 18, .16); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); transition: transform .2s ease, box-shadow .2s ease, opacity .2s ease, background .2s ease; }.login-submit.el-button:hover { transform: translateY(-1px); background: linear-gradient(135deg, rgba(194, 223, 255, .56), rgba(120, 168, 255, .56) 40%, rgba(84, 127, 255, .60)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .42), 0 22px 36px rgba(60, 103, 207, .22), 0 6px 14px rgba(4, 9, 18, .22); }.login-submit.el-button:active { transform: translateY(1px) scale(.99); }.login-submit.el-button.is-loading, .login-submit.el-button.is-disabled { opacity: .76; transform: none; }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .login-panel { background: linear-gradient(145deg, rgba(10, 18, 37, .72), rgba(4, 8, 19, .62)); }.login-form__input .el-input__wrapper { background: rgba(7, 15, 32, .72); } } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .login-panel { background: linear-gradient(145deg, rgba(10, 18, 37, .72), rgba(4, 8, 19, .62)); }.login-form__input .el-input__wrapper { background: rgba(7, 15, 32, .72); } }
@media (max-width: 980px) { .login-page { justify-content: center; padding-right: 24px; padding-left: 24px; background-position: 64% center; }.login-panel { margin: 0 auto; } } @media (max-width: 980px) { .login-page { justify-content: center; padding-right: 24px; padding-left: 24px; background-position: 64% center; }.login-panel { margin: 0 auto; } }
...@@ -336,16 +336,24 @@ body { margin: 0; } ...@@ -336,16 +336,24 @@ body { margin: 0; }
margin-bottom: 0; margin-bottom: 0;
} }
.login-form__input .el-input__wrapper { .login-form__input .el-input__wrapper,
background: linear-gradient(160deg, rgba(193, 212, 255, .011), rgba(193, 212, 255, .0015)); .login-form__input .el-input__wrapper:hover,
.login-form__input .el-input__wrapper.is-focus {
color-scheme: dark;
background: #0a1121 !important;
background-color: #0a1121 !important;
background-image: none !important;
border-color: rgba(205, 222, 255, .12); border-color: rgba(205, 222, 255, .12);
} }
.login-form__input .el-input__wrapper:hover { .login-form__input .el-input__wrapper:hover {
background: linear-gradient(160deg, rgba(193, 212, 255, .018), rgba(193, 212, 255, .003));
border-color: rgba(176, 205, 255, .23); border-color: rgba(176, 205, 255, .23);
} }
.login-form__input .el-input__wrapper.is-focus {
border-color: var(--login-field-focus);
}
.login-form__input .el-input__inner, .login-form__input .el-input__inner,
.login-form__input .el-input__inner:focus { .login-form__input .el-input__inner:focus {
color: var(--login-text-primary); color: var(--login-text-primary);
...@@ -358,18 +366,6 @@ body { margin: 0; } ...@@ -358,18 +366,6 @@ body { margin: 0; }
color: rgba(187, 204, 232, .54); color: rgba(187, 204, 232, .54);
} }
/* Chrome 自动填充会给 input 注入浅色底色;透明内阴影让它继续露出外层玻璃背景。 */
.login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover,
.login-form__input .el-input__inner:-webkit-autofill:focus,
.login-form__input .el-input__inner:-webkit-autofill:active {
-webkit-text-fill-color: var(--login-text-primary) !important;
caret-color: var(--login-text-primary);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
background-color: transparent !important;
}
.login-form__input .el-input__suffix-inner { .login-form__input .el-input__suffix-inner {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -494,22 +490,6 @@ body { margin: 0; } ...@@ -494,22 +490,6 @@ body { margin: 0; }
transform: translateY(-50%); transform: translateY(-50%);
} }
/* 登录输入文字在初始、聚焦和浏览器自动填充时使用同一字号,避免点击后出现字号跳变。 */
.login-form__input .el-input__inner,
.login-form__input .el-input__inner:focus,
.login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover,
.login-form__input .el-input__inner:-webkit-autofill:focus,
.login-form__input .el-input__inner:-webkit-autofill:active {
font-size: 16px !important;
line-height: 24px !important;
border: 0 !important;
outline: 0 !important;
-webkit-appearance: none;
appearance: none;
-webkit-text-size-adjust: 100%;
}
/* Chrome/Edge 自动填充时不在原生 input 内容区绘制背景,避免内阴影的矩形边缘形成横线。 */ /* Chrome/Edge 自动填充时不在原生 input 内容区绘制背景,避免内阴影的矩形边缘形成横线。 */
.login-form__input .el-input__inner:-webkit-autofill, .login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover, .login-form__input .el-input__inner:-webkit-autofill:hover,
...@@ -519,8 +499,11 @@ body { margin: 0; } ...@@ -519,8 +499,11 @@ body { margin: 0; }
caret-color: var(--login-text-primary); caret-color: var(--login-text-primary);
background: transparent !important; background: transparent !important;
background-color: transparent !important; background-color: transparent !important;
-webkit-box-shadow: 0 0 0 1000px transparent inset !important; background-image: none !important;
box-shadow: 0 0 0 1000px transparent inset !important; -webkit-background-clip: text !important;
background-clip: text !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
transition: none !important; transition: none !important;
} }
......
...@@ -15,10 +15,17 @@ async function mockUnauthenticatedSession(page) { ...@@ -15,10 +15,17 @@ async function mockUnauthenticatedSession(page) {
/** Plain purpose: submit one complete credential form so each scenario exercises the real login view bindings. Related files: LoginView.js, auth-store.js. Flow: input values -> submit -> auth API mock -> route/message. */ /** Plain purpose: submit one complete credential form so each scenario exercises the real login view bindings. Related files: LoginView.js, auth-store.js. Flow: input values -> submit -> auth API mock -> route/message. */
async function submitLogin(page, username = 'demo_operator', password = 'a-safe-test-password') { async function submitLogin(page, username = 'demo_operator', password = 'a-safe-test-password') {
await page.getByRole('textbox', { name: '账号' }).fill(username); await page.getByRole('textbox', { name: '账号' }).fill(username);
await page.getByRole('textbox', { name: '密码' }).fill(password); await typePassword(page, password);
await page.getByRole('button', { name: '登录' }).click(); await page.getByRole('button', { name: '登录' }).click();
} }
/** Code purpose (plain language): enter a test password through the visible native field and keyboard path that a person uses, without reading any password value. Related files: LoginView.js, app.css. Flow: native input click -> keyboard input -> v-model -> native browser password presentation. */
async function typePassword(page, password) {
const passwordInput = page.getByRole('textbox', { name: '密码' });
await passwordInput.click();
await passwordInput.pressSequentially(password);
}
/** Plain purpose: confirm a route guard protects business pages when no valid session exists. Related files: router/index.js, App.js. Flow: browser hash -> auth/me 401 -> login route -> no application shell. */ /** Plain purpose: confirm a route guard protects business pages when no valid session exists. Related files: router/index.js, App.js. Flow: browser hash -> auth/me 401 -> login route -> no application shell. */
test('redirects an unauthenticated protected route to login without the sidebar', async ({ page }) => { test('redirects an unauthenticated protected route to login without the sidebar', async ({ page }) => {
await mockUnauthenticatedSession(page); await mockUnauthenticatedSession(page);
...@@ -132,3 +139,26 @@ test('keeps the password input inside its autofill compatibility wrapper', async ...@@ -132,3 +139,26 @@ test('keeps the password input inside its autofill compatibility wrapper', async
await page.goto('/asset/#/login'); await page.goto('/asset/#/login');
await expect(page.locator('.login-password-field input[aria-label="密码"]')).toBeVisible(); await expect(page.locator('.login-password-field input[aria-label="密码"]')).toBeVisible();
}); });
/** Code purpose (plain language): protect the browser-owned password presentation by ensuring the login page adds no custom dots while keeping the native field and standard eye icon usable. Related files: LoginView.js, app.css. Flow: login render -> native input value -> browser password dots and Element Plus suffix icon. */
test('uses browser-native password presentation and the standard eye icon', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.goto('/asset/#/login');
const field = page.locator('.login-password-field');
const nativeInput = field.locator('input');
await expect(nativeInput).toBeVisible();
await typePassword(page, 'eye-icon-test');
await expect(field.locator('.login-password-mask')).toHaveCount(0);
await expect(field.locator('.el-input__password')).toBeVisible();
});
/** Code purpose (plain language): keep the password field as wide as the account field after the custom-dot wrapper is removed. Related files: LoginView.js, app.css. Flow: login form render -> wrapper width -> native input width -> visual layout regression check. */
test('keeps the password field the same width as the account field', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.goto('/asset/#/login');
const accountWidth = await page.getByRole('textbox', { name: '账号' }).locator('xpath=..').evaluate(wrapper => wrapper.getBoundingClientRect().width);
const passwordWidth = await page.getByRole('textbox', { name: '密码' }).locator('xpath=..').evaluate(wrapper => wrapper.getBoundingClientRect().width);
expect(passwordWidth).toBeCloseTo(accountWidth, 0);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment