feat: 支持账号导入与强制改密
- 新增账号导入模板下载、导入校验和默认密码重置标记 - 支持管理员重置密码并在登录后强制跳转修改密码 - 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
@@ -1,27 +1,32 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.log.annotation.LogRecord;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.log.annotation.LogRecord;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collection;
|
||||
@@ -62,13 +67,21 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
return Result.fail(1, "用户名已存在");
|
||||
}
|
||||
String password = entity.getPassword();
|
||||
if (StringUtil.hasText(password)) {
|
||||
entity.setPassword(BCrypt.hashpw(password));
|
||||
if (!StringUtil.hasText(password)) {
|
||||
return Result.fail(1, "密码不能为空");
|
||||
}
|
||||
SysPasswordPolicy.validateStrongPassword(password);
|
||||
entity.setPassword(BCrypt.hashpw(password));
|
||||
Integer status = entity.getStatus();
|
||||
if (status == null) {
|
||||
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
|
||||
}
|
||||
if (entity.getAccountType() == null) {
|
||||
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||
}
|
||||
if (entity.getPasswordResetRequired() == null) {
|
||||
entity.setPasswordResetRequired(false);
|
||||
}
|
||||
} else {
|
||||
SysAccount record = service.getById(entity.getId());
|
||||
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
|
||||
@@ -149,15 +162,40 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
if (!newPassword.equals(confirmPassword)) {
|
||||
return Result.fail(2, "两次密码不一致");
|
||||
}
|
||||
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||
SysAccount update = new SysAccount();
|
||||
update.setId(loginAccountId);
|
||||
update.setPassword(BCrypt.hashpw(newPassword));
|
||||
update.setPasswordResetRequired(false);
|
||||
update.setModified(new Date());
|
||||
update.setModifiedBy(loginAccountId);
|
||||
service.updateById(update);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/resetPassword")
|
||||
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/importExcel")
|
||||
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||
public Result<SysAccountImportResultVo> importExcel(MultipartFile file) {
|
||||
return Result.ok(service.importAccounts(file, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
@GetMapping("/downloadImportTemplate")
|
||||
@SaCheckPermission("/api/v1/sysAccount/query")
|
||||
public void downloadImportTemplate(HttpServletResponse response) throws Exception {
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding("utf-8");
|
||||
String fileName = URLEncoder.encode("user_import_template", "UTF-8").replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
|
||||
service.writeImportTemplate(response.getOutputStream());
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("save")
|
||||
public Result<?> save(@JsonBody SysAccount entity) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
@@ -80,12 +81,14 @@ public class UcSysAccountController {
|
||||
if (!newPassword.equals(confirmPassword)) {
|
||||
return Result.fail(2, "两次密码不一致");
|
||||
}
|
||||
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||
SysAccount update = new SysAccount();
|
||||
update.setId(loginAccountId);
|
||||
update.setPassword(BCrypt.hashpw(newPassword));
|
||||
update.setPasswordResetRequired(false);
|
||||
update.setModified(new Date());
|
||||
update.setModifiedBy(loginAccountId);
|
||||
service.updateById(update);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ public class LoginVO {
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 是否强制修改密码
|
||||
*/
|
||||
private boolean forceChangePassword;
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
@@ -38,4 +43,12 @@ public class LoginVO {
|
||||
public void setAvatar(String avatar) {
|
||||
this.avatar = avatar;
|
||||
}
|
||||
|
||||
public boolean isForceChangePassword() {
|
||||
return forceChangePassword;
|
||||
}
|
||||
|
||||
public void setForceChangePassword(boolean forceChangePassword) {
|
||||
this.forceChangePassword = forceChangePassword;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
res.setToken(StpUtil.getTokenValue());
|
||||
res.setNickname(record.getNickname());
|
||||
res.setAvatar(record.getAvatar());
|
||||
res.setForceChangePassword(Boolean.TRUE.equals(record.getPasswordResetRequired()));
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
||||
@Column(comment = "密码")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 是否需要重置密码
|
||||
*/
|
||||
@Column(comment = "是否需要重置密码")
|
||||
private Boolean passwordResetRequired;
|
||||
|
||||
/**
|
||||
* 账户类型
|
||||
*/
|
||||
@@ -149,6 +155,14 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Boolean getPasswordResetRequired() {
|
||||
return passwordResetRequired;
|
||||
}
|
||||
|
||||
public void setPasswordResetRequired(Boolean passwordResetRequired) {
|
||||
this.passwordResetRequired = passwordResetRequired;
|
||||
}
|
||||
|
||||
public Integer getAccountType() {
|
||||
return accountType;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package tech.easyflow.system.entity.vo;
|
||||
|
||||
/**
|
||||
* 用户导入失败行。
|
||||
*/
|
||||
public class SysAccountImportErrorRowVo {
|
||||
|
||||
private Integer rowNumber;
|
||||
|
||||
private String deptCode;
|
||||
|
||||
private String loginName;
|
||||
|
||||
private String reason;
|
||||
|
||||
public Integer getRowNumber() {
|
||||
return rowNumber;
|
||||
}
|
||||
|
||||
public void setRowNumber(Integer rowNumber) {
|
||||
this.rowNumber = rowNumber;
|
||||
}
|
||||
|
||||
public String getDeptCode() {
|
||||
return deptCode;
|
||||
}
|
||||
|
||||
public void setDeptCode(String deptCode) {
|
||||
this.deptCode = deptCode;
|
||||
}
|
||||
|
||||
public String getLoginName() {
|
||||
return loginName;
|
||||
}
|
||||
|
||||
public void setLoginName(String loginName) {
|
||||
this.loginName = loginName;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tech.easyflow.system.entity.vo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户导入结果。
|
||||
*/
|
||||
public class SysAccountImportResultVo {
|
||||
|
||||
private int successCount = 0;
|
||||
|
||||
private int errorCount = 0;
|
||||
|
||||
private int totalCount = 0;
|
||||
|
||||
private List<SysAccountImportErrorRowVo> errorRows = new ArrayList<>();
|
||||
|
||||
public int getSuccessCount() {
|
||||
return successCount;
|
||||
}
|
||||
|
||||
public void setSuccessCount(int successCount) {
|
||||
this.successCount = successCount;
|
||||
}
|
||||
|
||||
public int getErrorCount() {
|
||||
return errorCount;
|
||||
}
|
||||
|
||||
public void setErrorCount(int errorCount) {
|
||||
this.errorCount = errorCount;
|
||||
}
|
||||
|
||||
public int getTotalCount() {
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
public void setTotalCount(int totalCount) {
|
||||
this.totalCount = totalCount;
|
||||
}
|
||||
|
||||
public List<SysAccountImportErrorRowVo> getErrorRows() {
|
||||
return errorRows;
|
||||
}
|
||||
|
||||
public void setErrorRows(List<SysAccountImportErrorRowVo> errorRows) {
|
||||
this.errorRows = errorRows;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package tech.easyflow.system.service;
|
||||
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 用户表 服务层。
|
||||
@@ -14,4 +20,10 @@ public interface SysAccountService extends IService<SysAccount> {
|
||||
void syncRelations(SysAccount entity);
|
||||
|
||||
SysAccount getByUsername(String userKey);
|
||||
|
||||
void resetPassword(BigInteger accountId, BigInteger operatorId);
|
||||
|
||||
SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount);
|
||||
|
||||
void writeImportTemplate(OutputStream outputStream);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
package tech.easyflow.system.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import cn.idev.excel.EasyExcel;
|
||||
import cn.idev.excel.FastExcel;
|
||||
import cn.idev.excel.context.AnalysisContext;
|
||||
import cn.idev.excel.metadata.data.ReadCellData;
|
||||
import cn.idev.excel.read.listener.ReadListener;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.SysAccountPosition;
|
||||
import tech.easyflow.system.entity.SysAccountRole;
|
||||
import tech.easyflow.system.entity.SysDept;
|
||||
import tech.easyflow.system.entity.SysPosition;
|
||||
import tech.easyflow.system.entity.SysRole;
|
||||
import tech.easyflow.system.entity.vo.SysAccountImportErrorRowVo;
|
||||
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||
import tech.easyflow.system.mapper.SysAccountMapper;
|
||||
import tech.easyflow.system.mapper.SysAccountPositionMapper;
|
||||
import tech.easyflow.system.mapper.SysAccountRoleMapper;
|
||||
import tech.easyflow.system.mapper.SysDeptMapper;
|
||||
import tech.easyflow.system.mapper.SysPositionMapper;
|
||||
import tech.easyflow.system.mapper.SysRoleMapper;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.Duration;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -34,6 +63,18 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
||||
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final String DEFAULT_RESET_PASSWORD = "123456";
|
||||
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
||||
private static final int MAX_IMPORT_ROWS = 5000;
|
||||
private static final String IMPORT_HEAD_DEPT_CODE = "部门编码";
|
||||
private static final String IMPORT_HEAD_LOGIN_NAME = "登录账号";
|
||||
private static final String IMPORT_HEAD_NICKNAME = "昵称";
|
||||
private static final String IMPORT_HEAD_MOBILE = "手机号";
|
||||
private static final String IMPORT_HEAD_EMAIL = "邮箱";
|
||||
private static final String IMPORT_HEAD_STATUS = "状态";
|
||||
private static final String IMPORT_HEAD_ROLE_KEYS = "角色编码";
|
||||
private static final String IMPORT_HEAD_POSITION_CODES = "岗位编码";
|
||||
private static final String IMPORT_HEAD_REMARK = "备注";
|
||||
|
||||
@Resource
|
||||
private SysAccountRoleMapper sysAccountRoleMapper;
|
||||
@@ -42,7 +83,13 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
@Resource
|
||||
private SysRoleMapper sysRoleMapper;
|
||||
@Resource
|
||||
private SysPositionMapper sysPositionMapper;
|
||||
@Resource
|
||||
private SysDeptMapper sysDeptMapper;
|
||||
@Resource
|
||||
private RedisLockExecutor redisLockExecutor;
|
||||
@Resource
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -55,7 +102,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
LOCK_WAIT_TIMEOUT,
|
||||
LOCK_LEASE_TIMEOUT,
|
||||
() -> {
|
||||
//sync roleIds
|
||||
List<BigInteger> roleIds = entity.getRoleIds();
|
||||
if (roleIds != null) {
|
||||
QueryWrapper delW = QueryWrapper.create();
|
||||
@@ -79,7 +125,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
}
|
||||
}
|
||||
|
||||
//sync positionIds
|
||||
List<BigInteger> positionIds = entity.getPositionIds();
|
||||
if (positionIds != null) {
|
||||
QueryWrapper delW = QueryWrapper.create();
|
||||
@@ -112,4 +157,483 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
w.eq(SysAccount::getLoginName, userKey);
|
||||
return getOne(w);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(BigInteger accountId, BigInteger operatorId) {
|
||||
SysAccount record = getById(accountId);
|
||||
if (record == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
Integer accountType = record.getAccountType();
|
||||
if (EnumAccountType.SUPER_ADMIN.getCode().equals(accountType)) {
|
||||
throw new BusinessException("不能重置超级管理员密码");
|
||||
}
|
||||
if (EnumAccountType.TENANT_ADMIN.getCode().equals(accountType)) {
|
||||
throw new BusinessException("不能重置租户管理员密码");
|
||||
}
|
||||
SysAccount update = new SysAccount();
|
||||
update.setId(accountId);
|
||||
update.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||
update.setPasswordResetRequired(true);
|
||||
update.setModified(new Date());
|
||||
update.setModifiedBy(operatorId);
|
||||
updateById(update);
|
||||
StpUtil.kickout(accountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount) {
|
||||
validateImportFile(file);
|
||||
List<SysAccountImportRow> rows = parseImportRows(file);
|
||||
SysAccountImportResultVo result = new SysAccountImportResultVo();
|
||||
result.setTotalCount(rows.size());
|
||||
if (rows.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, SysDept> deptMap = buildDeptCodeMap();
|
||||
Map<String, SysRole> roleMap = buildRoleKeyMap();
|
||||
Map<String, SysPosition> positionMap = buildPositionCodeMap();
|
||||
for (SysAccountImportRow row : rows) {
|
||||
try {
|
||||
executeInRowTransaction(() -> importSingleRow(row, loginAccount, deptMap, roleMap, positionMap));
|
||||
result.setSuccessCount(result.getSuccessCount() + 1);
|
||||
} catch (Exception e) {
|
||||
result.setErrorCount(result.getErrorCount() + 1);
|
||||
appendImportError(result, row, extractImportErrorMessage(e));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeImportTemplate(OutputStream outputStream) {
|
||||
EasyExcel.write(outputStream)
|
||||
.head(buildImportHeadList())
|
||||
.sheet("模板")
|
||||
.doWrite(new ArrayList<>());
|
||||
}
|
||||
|
||||
private void executeInRowTransaction(Runnable action) {
|
||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||
template.executeWithoutResult(status -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
status.setRollbackOnly();
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void importSingleRow(SysAccountImportRow row,
|
||||
LoginAccount loginAccount,
|
||||
Map<String, SysDept> deptMap,
|
||||
Map<String, SysRole> roleMap,
|
||||
Map<String, SysPosition> positionMap) {
|
||||
String deptCode = trimToNull(row.getDeptCode());
|
||||
String loginName = trimToNull(row.getLoginName());
|
||||
String nickname = trimToNull(row.getNickname());
|
||||
if (deptCode == null) {
|
||||
throw new BusinessException("部门编码不能为空");
|
||||
}
|
||||
if (loginName == null) {
|
||||
throw new BusinessException("登录账号不能为空");
|
||||
}
|
||||
if (nickname == null) {
|
||||
throw new BusinessException("昵称不能为空");
|
||||
}
|
||||
SysDept dept = deptMap.get(deptCode);
|
||||
if (dept == null) {
|
||||
throw new BusinessException("部门编码不存在: " + deptCode);
|
||||
}
|
||||
ensureLoginNameNotExists(loginName);
|
||||
|
||||
List<BigInteger> roleIds = resolveRoleIds(row.getRoleKeys(), roleMap);
|
||||
List<BigInteger> positionIds = resolvePositionIds(row.getPositionCodes(), positionMap);
|
||||
|
||||
SysAccount entity = new SysAccount();
|
||||
entity.setDeptId(dept.getId());
|
||||
entity.setTenantId(loginAccount.getTenantId());
|
||||
entity.setLoginName(loginName);
|
||||
entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||
entity.setPasswordResetRequired(true);
|
||||
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||
entity.setNickname(nickname);
|
||||
entity.setMobile(trimToNull(row.getMobile()));
|
||||
entity.setEmail(trimToNull(row.getEmail()));
|
||||
entity.setStatus(parseStatus(row.getStatus()));
|
||||
entity.setRemark(trimToNull(row.getRemark()));
|
||||
entity.setCreated(new Date());
|
||||
entity.setCreatedBy(loginAccount.getId());
|
||||
entity.setModified(new Date());
|
||||
entity.setModifiedBy(loginAccount.getId());
|
||||
entity.setRoleIds(roleIds);
|
||||
entity.setPositionIds(positionIds);
|
||||
save(entity);
|
||||
syncRelations(entity);
|
||||
}
|
||||
|
||||
private void ensureLoginNameNotExists(String loginName) {
|
||||
QueryWrapper wrapper = QueryWrapper.create();
|
||||
wrapper.eq(SysAccount::getLoginName, loginName);
|
||||
if (count(wrapper) > 0) {
|
||||
throw new BusinessException("登录账号已存在: " + loginName);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer parseStatus(String rawStatus) {
|
||||
String status = trimToNull(rawStatus);
|
||||
if (status == null) {
|
||||
return EnumDataStatus.AVAILABLE.getCode();
|
||||
}
|
||||
if ("1".equals(status) || "已启用".equals(status) || "启用".equals(status)) {
|
||||
return EnumDataStatus.AVAILABLE.getCode();
|
||||
}
|
||||
if ("0".equals(status) || "未启用".equals(status) || "停用".equals(status) || "禁用".equals(status)) {
|
||||
return EnumDataStatus.UNAVAILABLE.getCode();
|
||||
}
|
||||
throw new BusinessException("状态不合法,仅支持 1/0 或 已启用/未启用");
|
||||
}
|
||||
|
||||
private List<BigInteger> resolveRoleIds(String roleKeysText, Map<String, SysRole> roleMap) {
|
||||
List<String> roleKeys = splitCodes(roleKeysText);
|
||||
if (roleKeys.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<BigInteger> roleIds = new ArrayList<>(roleKeys.size());
|
||||
for (String roleKey : roleKeys) {
|
||||
SysRole role = roleMap.get(roleKey);
|
||||
if (role == null) {
|
||||
throw new BusinessException("角色编码不存在: " + roleKey);
|
||||
}
|
||||
roleIds.add(role.getId());
|
||||
}
|
||||
return roleIds;
|
||||
}
|
||||
|
||||
private List<BigInteger> resolvePositionIds(String positionCodesText, Map<String, SysPosition> positionMap) {
|
||||
List<String> positionCodes = splitCodes(positionCodesText);
|
||||
if (positionCodes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<BigInteger> positionIds = new ArrayList<>(positionCodes.size());
|
||||
for (String positionCode : positionCodes) {
|
||||
SysPosition position = positionMap.get(positionCode);
|
||||
if (position == null) {
|
||||
throw new BusinessException("岗位编码不存在: " + positionCode);
|
||||
}
|
||||
positionIds.add(position.getId());
|
||||
}
|
||||
return positionIds;
|
||||
}
|
||||
|
||||
private List<String> splitCodes(String rawCodes) {
|
||||
String codes = trimToNull(rawCodes);
|
||||
if (codes == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String[] values = codes.split("[,,]");
|
||||
List<String> result = new ArrayList<>();
|
||||
Set<String> uniqueValues = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
String trimmed = trimToNull(value);
|
||||
if (trimmed != null && uniqueValues.add(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, SysDept> buildDeptCodeMap() {
|
||||
List<SysDept> deptList = sysDeptMapper.selectListByQuery(QueryWrapper.create());
|
||||
Map<String, SysDept> deptMap = new HashMap<>();
|
||||
for (SysDept dept : deptList) {
|
||||
String deptCode = trimToNull(dept.getDeptCode());
|
||||
if (deptCode != null) {
|
||||
deptMap.putIfAbsent(deptCode, dept);
|
||||
}
|
||||
}
|
||||
return deptMap;
|
||||
}
|
||||
|
||||
private Map<String, SysRole> buildRoleKeyMap() {
|
||||
List<SysRole> roleList = sysRoleMapper.selectListByQuery(QueryWrapper.create());
|
||||
Map<String, SysRole> roleMap = new HashMap<>();
|
||||
for (SysRole role : roleList) {
|
||||
String roleKey = trimToNull(role.getRoleKey());
|
||||
if (roleKey != null) {
|
||||
roleMap.putIfAbsent(roleKey, role);
|
||||
}
|
||||
}
|
||||
return roleMap;
|
||||
}
|
||||
|
||||
private Map<String, SysPosition> buildPositionCodeMap() {
|
||||
List<SysPosition> positionList = sysPositionMapper.selectListByQuery(QueryWrapper.create());
|
||||
Map<String, SysPosition> positionMap = new HashMap<>();
|
||||
for (SysPosition position : positionList) {
|
||||
String positionCode = trimToNull(position.getPositionCode());
|
||||
if (positionCode != null) {
|
||||
positionMap.putIfAbsent(positionCode, position);
|
||||
}
|
||||
}
|
||||
return positionMap;
|
||||
}
|
||||
|
||||
private List<SysAccountImportRow> parseImportRows(MultipartFile file) {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
SysAccountExcelReadListener listener = new SysAccountExcelReadListener();
|
||||
FastExcel.read(inputStream, listener)
|
||||
.sheet()
|
||||
.doRead();
|
||||
return listener.getRows();
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("用户导入文件解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateImportFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("导入文件不能为空");
|
||||
}
|
||||
if (file.getSize() > MAX_IMPORT_FILE_SIZE_BYTES) {
|
||||
throw new BusinessException("导入文件大小不能超过10MB");
|
||||
}
|
||||
String fileName = file.getOriginalFilename();
|
||||
String lowerName = fileName == null ? "" : fileName.toLowerCase();
|
||||
if (!(lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls"))) {
|
||||
throw new BusinessException("仅支持 xlsx/xls 文件");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendImportError(SysAccountImportResultVo result, SysAccountImportRow row, String reason) {
|
||||
SysAccountImportErrorRowVo errorRow = new SysAccountImportErrorRowVo();
|
||||
errorRow.setRowNumber(row.getRowNumber());
|
||||
errorRow.setDeptCode(row.getDeptCode());
|
||||
errorRow.setLoginName(row.getLoginName());
|
||||
errorRow.setReason(reason);
|
||||
result.getErrorRows().add(errorRow);
|
||||
}
|
||||
|
||||
private String extractImportErrorMessage(Exception e) {
|
||||
if (e == null) {
|
||||
return "导入失败";
|
||||
}
|
||||
if (e.getCause() != null && StringUtil.hasText(e.getCause().getMessage())) {
|
||||
return e.getCause().getMessage();
|
||||
}
|
||||
if (StringUtil.hasText(e.getMessage())) {
|
||||
return e.getMessage();
|
||||
}
|
||||
return "导入失败";
|
||||
}
|
||||
|
||||
private List<List<String>> buildImportHeadList() {
|
||||
List<List<String>> headList = new ArrayList<>(9);
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_CODE));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_LOGIN_NAME));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_EMAIL));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_STATUS));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_ROLE_KEYS));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_POSITION_CODES));
|
||||
headList.add(Collections.singletonList(IMPORT_HEAD_REMARK));
|
||||
return headList;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (!StringUtil.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static class SysAccountImportRow {
|
||||
private Integer rowNumber;
|
||||
private String deptCode;
|
||||
private String loginName;
|
||||
private String nickname;
|
||||
private String mobile;
|
||||
private String email;
|
||||
private String status;
|
||||
private String roleKeys;
|
||||
private String positionCodes;
|
||||
private String remark;
|
||||
|
||||
public Integer getRowNumber() {
|
||||
return rowNumber;
|
||||
}
|
||||
|
||||
public void setRowNumber(Integer rowNumber) {
|
||||
this.rowNumber = rowNumber;
|
||||
}
|
||||
|
||||
public String getDeptCode() {
|
||||
return deptCode;
|
||||
}
|
||||
|
||||
public void setDeptCode(String deptCode) {
|
||||
this.deptCode = deptCode;
|
||||
}
|
||||
|
||||
public String getLoginName() {
|
||||
return loginName;
|
||||
}
|
||||
|
||||
public void setLoginName(String loginName) {
|
||||
this.loginName = loginName;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public String getMobile() {
|
||||
return mobile;
|
||||
}
|
||||
|
||||
public void setMobile(String mobile) {
|
||||
this.mobile = mobile;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getRoleKeys() {
|
||||
return roleKeys;
|
||||
}
|
||||
|
||||
public void setRoleKeys(String roleKeys) {
|
||||
this.roleKeys = roleKeys;
|
||||
}
|
||||
|
||||
public String getPositionCodes() {
|
||||
return positionCodes;
|
||||
}
|
||||
|
||||
public void setPositionCodes(String positionCodes) {
|
||||
this.positionCodes = positionCodes;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
|
||||
private class SysAccountExcelReadListener implements ReadListener<LinkedHashMap<Integer, Object>> {
|
||||
|
||||
private final Map<String, Integer> headIndex = new HashMap<>();
|
||||
private final List<SysAccountImportRow> rows = new ArrayList<>();
|
||||
private int sheetRowNo;
|
||||
|
||||
@Override
|
||||
public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) {
|
||||
sheetRowNo++;
|
||||
String deptCode = getCellValue(data, IMPORT_HEAD_DEPT_CODE);
|
||||
String loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME);
|
||||
String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME);
|
||||
String mobile = getCellValue(data, IMPORT_HEAD_MOBILE);
|
||||
String email = getCellValue(data, IMPORT_HEAD_EMAIL);
|
||||
String status = getCellValue(data, IMPORT_HEAD_STATUS);
|
||||
String roleKeys = getCellValue(data, IMPORT_HEAD_ROLE_KEYS);
|
||||
String positionCodes = getCellValue(data, IMPORT_HEAD_POSITION_CODES);
|
||||
String remark = getCellValue(data, IMPORT_HEAD_REMARK);
|
||||
if (!StringUtil.hasText(deptCode)
|
||||
&& !StringUtil.hasText(loginName)
|
||||
&& !StringUtil.hasText(nickname)
|
||||
&& !StringUtil.hasText(mobile)
|
||||
&& !StringUtil.hasText(email)
|
||||
&& !StringUtil.hasText(status)
|
||||
&& !StringUtil.hasText(roleKeys)
|
||||
&& !StringUtil.hasText(positionCodes)
|
||||
&& !StringUtil.hasText(remark)) {
|
||||
return;
|
||||
}
|
||||
if (rows.size() >= MAX_IMPORT_ROWS) {
|
||||
throw new BusinessException("单次最多导入5000个用户");
|
||||
}
|
||||
SysAccountImportRow row = new SysAccountImportRow();
|
||||
row.setRowNumber(sheetRowNo + 1);
|
||||
row.setDeptCode(deptCode);
|
||||
row.setLoginName(loginName);
|
||||
row.setNickname(nickname);
|
||||
row.setMobile(mobile);
|
||||
row.setEmail(email);
|
||||
row.setStatus(status);
|
||||
row.setRoleKeys(roleKeys);
|
||||
row.setPositionCodes(positionCodes);
|
||||
row.setRemark(remark);
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
|
||||
for (Map.Entry<Integer, ReadCellData<?>> entry : headMap.entrySet()) {
|
||||
String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue();
|
||||
String header = trimToNull(headValue);
|
||||
if (header != null) {
|
||||
headIndex.put(header, entry.getKey());
|
||||
}
|
||||
}
|
||||
List<String> requiredHeads = List.of(
|
||||
IMPORT_HEAD_DEPT_CODE,
|
||||
IMPORT_HEAD_LOGIN_NAME,
|
||||
IMPORT_HEAD_NICKNAME,
|
||||
IMPORT_HEAD_MOBILE,
|
||||
IMPORT_HEAD_EMAIL,
|
||||
IMPORT_HEAD_STATUS,
|
||||
IMPORT_HEAD_ROLE_KEYS,
|
||||
IMPORT_HEAD_POSITION_CODES,
|
||||
IMPORT_HEAD_REMARK
|
||||
);
|
||||
for (String requiredHead : requiredHeads) {
|
||||
if (!headIndex.containsKey(requiredHead)) {
|
||||
throw new BusinessException("导入模板表头不正确,必须包含:" + String.join("、", requiredHeads));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
public List<SysAccountImportRow> getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
private String getCellValue(Map<Integer, Object> row, String headName) {
|
||||
Integer index = headIndex.get(headName);
|
||||
if (index == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = row.get(index);
|
||||
return value == null ? null : String.valueOf(value).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package tech.easyflow.system.util;
|
||||
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 用户密码策略。
|
||||
*/
|
||||
public final class SysPasswordPolicy {
|
||||
|
||||
public static final String STRONG_PASSWORD_MESSAGE = "密码必须至少8位,且包含大写字母、小写字母、数字和特殊字符";
|
||||
|
||||
private static final Pattern STRONG_PASSWORD_PATTERN = Pattern.compile(
|
||||
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,}$"
|
||||
);
|
||||
|
||||
private SysPasswordPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isStrongPassword(String password) {
|
||||
return password != null && STRONG_PASSWORD_PATTERN.matcher(password).matches();
|
||||
}
|
||||
|
||||
public static void validateStrongPassword(String password) {
|
||||
if (!isStrongPassword(password)) {
|
||||
throw new BusinessException(STRONG_PASSWORD_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package tech.easyflow.system.util;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
public class SysPasswordPolicyTest {
|
||||
|
||||
@Test
|
||||
public void shouldAcceptStrongPassword() {
|
||||
Assert.assertTrue(SysPasswordPolicy.isStrongPassword("Abcd1234!"));
|
||||
SysPasswordPolicy.validateStrongPassword("Abcd1234!");
|
||||
}
|
||||
|
||||
@Test(expected = BusinessException.class)
|
||||
public void shouldRejectWeakPassword() {
|
||||
Assert.assertFalse(SysPasswordPolicy.isStrongPassword("123456"));
|
||||
SysPasswordPolicy.validateStrongPassword("123456");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export namespace AuthApi {
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
forceChangePassword?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"newPwd": "NewPassword",
|
||||
"confirmPwd": "ConfirmPassword",
|
||||
"repeatPwd": "Please confirm your password again",
|
||||
"notSamePwd": "The two passwords are inconsistent"
|
||||
"notSamePwd": "The two passwords are inconsistent",
|
||||
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
||||
"resetPassword": "Reset Password",
|
||||
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
|
||||
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
|
||||
"importTitle": "Import Users",
|
||||
"importUploadTitle": "Drag the Excel file here, or click to select a file",
|
||||
"importUploadDesc": "Only .xlsx / .xls files are supported. Import only creates users and duplicate accounts will fail.",
|
||||
"importSelectFileRequired": "Please select a file to import",
|
||||
"downloadTemplate": "Download Template",
|
||||
"importFinished": "User import completed",
|
||||
"importResultTitle": "Import Result",
|
||||
"importTotalCount": "Total",
|
||||
"importSuccessCount": "Success",
|
||||
"importErrorCount": "Failed",
|
||||
"importRowNumber": "Row",
|
||||
"importDeptCode": "Dept Code",
|
||||
"importReason": "Reason"
|
||||
}
|
||||
|
||||
@@ -24,5 +24,23 @@
|
||||
"newPwd": "新密码",
|
||||
"confirmPwd": "确认密码",
|
||||
"repeatPwd": "请再次输入密码",
|
||||
"notSamePwd": "两次输入的密码不一致"
|
||||
"notSamePwd": "两次输入的密码不一致",
|
||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
||||
"resetPassword": "重置密码",
|
||||
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
|
||||
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
|
||||
"importTitle": "导入用户",
|
||||
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
|
||||
"importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错",
|
||||
"importSelectFileRequired": "请先选择要导入的文件",
|
||||
"downloadTemplate": "下载导入模板",
|
||||
"importFinished": "用户导入完成",
|
||||
"importResultTitle": "导入结果",
|
||||
"importTotalCount": "总条数",
|
||||
"importSuccessCount": "成功数",
|
||||
"importErrorCount": "失败数",
|
||||
"importRowNumber": "行号",
|
||||
"importDeptCode": "部门编码",
|
||||
"importReason": "失败原因"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
removeDevLoginQuery,
|
||||
shouldAttemptDevLogin,
|
||||
} from './dev-login';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
isForcePasswordRoute,
|
||||
notifyForcePasswordChange,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
interface NetworkConnectionLike {
|
||||
effectiveType?: string;
|
||||
@@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) {
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(currentUser)) {
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
return decodeURIComponent(
|
||||
(to.query?.redirect as string) ||
|
||||
userStore.userInfo?.homePath ||
|
||||
@@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) {
|
||||
return to;
|
||||
}
|
||||
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||
if (from.name) {
|
||||
notifyForcePasswordChange();
|
||||
}
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
@@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 生成路由表
|
||||
// 当前登录用户拥有的角色标识列表
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
const userRoles = userInfo.roles ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
logoutApi,
|
||||
} from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
@@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function finalizeLogin(
|
||||
accessToken: string,
|
||||
forceChangePassword?: boolean,
|
||||
options: {
|
||||
notify?: boolean;
|
||||
onSuccess?: () => Promise<void> | void;
|
||||
@@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
const forcePasswordChange = shouldForcePasswordChange(
|
||||
userInfo,
|
||||
forceChangePassword,
|
||||
);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
}
|
||||
|
||||
if (forcePasswordChange) {
|
||||
await router.push(buildForcePasswordRoute());
|
||||
} else if (!options.skipRedirect) {
|
||||
const homePath =
|
||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||
@@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
) {
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { token: accessToken } = await loginApi(params);
|
||||
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||
|
||||
if (accessToken) {
|
||||
return await finalizeLogin(accessToken, { onSuccess });
|
||||
return await finalizeLogin(accessToken, forceChangePassword, {
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
@@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function authDevLogin(account: string) {
|
||||
const { token: accessToken } = await devLoginApi({ account });
|
||||
const { forceChangePassword, token: accessToken } = await devLoginApi({
|
||||
account,
|
||||
});
|
||||
if (!accessToken) {
|
||||
return {
|
||||
userInfo: null,
|
||||
};
|
||||
}
|
||||
return finalizeLogin(accessToken, {
|
||||
return finalizeLogin(accessToken, forceChangePassword, {
|
||||
notify: false,
|
||||
skipRedirect: true,
|
||||
});
|
||||
|
||||
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const strongPasswordPattern =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||
|
||||
export function isStrongPassword(value?: string) {
|
||||
return !!value && strongPasswordPattern.test(value);
|
||||
}
|
||||
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||
|
||||
let lastForcePasswordNoticeAt = 0;
|
||||
|
||||
export function buildForcePasswordRoute() {
|
||||
return {
|
||||
name: 'Profile',
|
||||
query: {
|
||||
force: '1',
|
||||
tab: 'password',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isForcePasswordRoute(
|
||||
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||
) {
|
||||
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||
}
|
||||
|
||||
export function shouldForcePasswordChange(
|
||||
userInfo?: null | Record<string, any>,
|
||||
forceChangePassword?: boolean,
|
||||
) {
|
||||
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||
}
|
||||
|
||||
export function resolveForcePasswordPath(router: Router) {
|
||||
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||
}
|
||||
|
||||
export function notifyForcePasswordChange() {
|
||||
const now = Date.now();
|
||||
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
lastForcePasswordNoticeAt = now;
|
||||
ElMessage({
|
||||
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||
type: 'warning',
|
||||
duration: 2200,
|
||||
grouping: true,
|
||||
plain: true,
|
||||
showClose: true,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Profile } from '@easyflow/common-ui';
|
||||
@@ -17,22 +17,42 @@ const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: $t('settingsConfig.basic'),
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.tab) {
|
||||
tabsValue.value = route.query.tab as string;
|
||||
}
|
||||
const forcePasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const tabs = computed(() => {
|
||||
if (forcePasswordChange.value) {
|
||||
return [
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: $t('settingsConfig.basic'),
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||
() => {
|
||||
if (forcePasswordChange.value) {
|
||||
tabsValue.value = 'password';
|
||||
return;
|
||||
}
|
||||
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
@@ -43,8 +63,8 @@ onMounted(() => {
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
|
||||
@@ -2,15 +2,28 @@
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
|
||||
const profilePasswordSettingRef = ref();
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const isForcedPasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
return [
|
||||
@@ -30,6 +43,17 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||
},
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||
};
|
||||
},
|
||||
rules: z
|
||||
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.refine((value) => isStrongPassword(value), {
|
||||
message: $t('sysAccount.passwordStrongTip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
@@ -56,14 +80,22 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
});
|
||||
|
||||
const updateLoading = ref(false);
|
||||
function handleSubmit(values: any) {
|
||||
async function handleSubmit(values: any) {
|
||||
updateLoading.value = true;
|
||||
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
|
||||
updateLoading.value = false;
|
||||
try {
|
||||
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.success'));
|
||||
const userInfo = await authStore.fetchUserInfo();
|
||||
if (isForcedPasswordChange.value) {
|
||||
await router.replace(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
updateLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadFile } from 'element-plus';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
import {
|
||||
CircleCloseFilled,
|
||||
Document,
|
||||
Download,
|
||||
SuccessFilled,
|
||||
UploadFilled,
|
||||
WarningFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const fileList = ref<any[]>([]);
|
||||
const currentFile = ref<File | null>(null);
|
||||
const submitLoading = ref(false);
|
||||
const downloadLoading = ref(false);
|
||||
const importResult = ref<any>(null);
|
||||
|
||||
const hasErrors = computed(() => (importResult.value?.errorCount || 0) > 0);
|
||||
const selectedFileName = computed(() => currentFile.value?.name || '');
|
||||
|
||||
function openDialog() {
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (submitLoading.value) {
|
||||
return;
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
resetDialog();
|
||||
}
|
||||
|
||||
function resetDialog() {
|
||||
fileList.value = [];
|
||||
currentFile.value = null;
|
||||
submitLoading.value = false;
|
||||
downloadLoading.value = false;
|
||||
importResult.value = null;
|
||||
}
|
||||
|
||||
function onFileChange(uploadFile: UploadFile) {
|
||||
currentFile.value = uploadFile.raw || null;
|
||||
fileList.value = uploadFile.raw ? [uploadFile] : [];
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearSelectedFile() {
|
||||
currentFile.value = null;
|
||||
fileList.value = [];
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
downloadLoading.value = true;
|
||||
try {
|
||||
const blob = await api.download('/api/v1/sysAccount/downloadImportTemplate');
|
||||
downloadFileFromBlob({
|
||||
fileName: 'user_import_template.xlsx',
|
||||
source: blob,
|
||||
});
|
||||
} finally {
|
||||
downloadLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!currentFile.value) {
|
||||
ElMessage.warning($t('sysAccount.importSelectFileRequired'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', currentFile.value);
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const res = await api.postFile('/api/v1/sysAccount/importExcel', formData, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
importResult.value = res.data;
|
||||
ElMessage.success($t('sysAccount.importFinished'));
|
||||
emit('reload');
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowPanelModal
|
||||
v-model:open="dialogVisible"
|
||||
width="min(960px, 92vw)"
|
||||
:title="$t('sysAccount.importTitle')"
|
||||
:before-close="closeDialog"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<div class="sys-account-import-dialog">
|
||||
<ElUpload
|
||||
:file-list="fileList"
|
||||
drag
|
||||
action="#"
|
||||
accept=".xlsx,.xls"
|
||||
:auto-upload="false"
|
||||
:on-change="onFileChange"
|
||||
:limit="1"
|
||||
:show-file-list="false"
|
||||
class="sys-account-upload-area"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-2 px-8 py-10 text-center"
|
||||
>
|
||||
<ElIcon class="text-4xl text-[var(--el-text-color-secondary)]">
|
||||
<UploadFilled />
|
||||
</ElIcon>
|
||||
<div class="text-[15px] font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ $t('sysAccount.importUploadTitle') }}
|
||||
</div>
|
||||
<div class="text-[13px] text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('sysAccount.importUploadDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElUpload>
|
||||
|
||||
<div v-if="selectedFileName" class="selected-file-card">
|
||||
<div class="selected-file-main">
|
||||
<ElIcon class="selected-file-icon"><Document /></ElIcon>
|
||||
<div class="selected-file-name" :title="selectedFileName">
|
||||
{{ selectedFileName }}
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
:icon="CircleCloseFilled"
|
||||
class="remove-file-btn"
|
||||
@click="clearSelectedFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="importResult" class="result-wrap">
|
||||
<div class="result-head">
|
||||
<div class="result-title-wrap">
|
||||
<ElIcon
|
||||
v-if="hasErrors"
|
||||
class="result-state-icon text-[var(--el-color-warning)]"
|
||||
>
|
||||
<WarningFilled />
|
||||
</ElIcon>
|
||||
<ElIcon
|
||||
v-else
|
||||
class="result-state-icon text-[var(--el-color-success)]"
|
||||
>
|
||||
<SuccessFilled />
|
||||
</ElIcon>
|
||||
<span class="result-title-text">
|
||||
{{ $t('sysAccount.importResultTitle') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importTotalCount') }}</div>
|
||||
<div class="stat-value">{{ importResult.totalCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importSuccessCount') }}</div>
|
||||
<div class="stat-value success-text">
|
||||
{{ importResult.successCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importErrorCount') }}</div>
|
||||
<div class="stat-value" :class="hasErrors ? 'danger-text' : ''">
|
||||
{{ importResult.errorCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-if="hasErrors"
|
||||
:data="importResult.errorRows || []"
|
||||
size="small"
|
||||
class="result-error-table"
|
||||
>
|
||||
<ElTableColumn
|
||||
prop="rowNumber"
|
||||
:label="$t('sysAccount.importRowNumber')"
|
||||
width="96"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="deptCode"
|
||||
:label="$t('sysAccount.importDeptCode')"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="loginName"
|
||||
:label="$t('sysAccount.loginName')"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="reason"
|
||||
:label="$t('sysAccount.importReason')"
|
||||
min-width="260"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
:disabled="downloadLoading"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
{{ $t('sysAccount.downloadTemplate') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="submitLoading"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ $t('button.startImport') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</EasyFlowPanelModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sys-account-import-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sys-account-upload-area :deep(.el-upload-dragger) {
|
||||
border-radius: 18px;
|
||||
border-color: hsl(var(--border) / 0.68);
|
||||
background: hsl(var(--surface) / 0.72);
|
||||
}
|
||||
|
||||
.selected-file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid hsl(var(--border) / 0.72);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
background: hsl(var(--surface-subtle) / 0.95);
|
||||
}
|
||||
|
||||
.selected-file-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected-file-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.selected-file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.result-wrap {
|
||||
border: 1px solid hsl(var(--border) / 0.72);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
background: hsl(var(--surface-subtle) / 0.94);
|
||||
}
|
||||
|
||||
.result-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
background: hsl(var(--surface) / 0.95);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,13 @@ import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { markRaw, onMounted, ref } from 'vue';
|
||||
|
||||
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
Delete,
|
||||
Lock,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
@@ -24,6 +30,7 @@ import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
import SysAccountImportModal from './SysAccountImportModal.vue';
|
||||
import SysAccountModal from './SysAccountModal.vue';
|
||||
|
||||
onMounted(() => {
|
||||
@@ -31,6 +38,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const pageDataRef = ref();
|
||||
const importDialog = ref();
|
||||
const saveDialog = ref();
|
||||
const dictStore = useDictStore();
|
||||
const headerButtons = [
|
||||
@@ -42,6 +50,13 @@ const headerButtons = [
|
||||
data: { action: 'create' },
|
||||
permission: '/api/v1/sysAccount/save',
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
text: $t('button.import'),
|
||||
icon: markRaw(Upload),
|
||||
data: { action: 'import' },
|
||||
permission: '/api/v1/sysAccount/save',
|
||||
},
|
||||
];
|
||||
|
||||
function initDict() {
|
||||
@@ -57,6 +72,16 @@ function reset(formEl?: FormInstance) {
|
||||
function showDialog(row: any) {
|
||||
saveDialog.value.openDialog({ ...row });
|
||||
}
|
||||
function openImportDialog() {
|
||||
importDialog.value.openDialog();
|
||||
}
|
||||
function handleHeaderButtonClick(payload: any) {
|
||||
if (payload?.key === 'import') {
|
||||
openImportDialog();
|
||||
return;
|
||||
}
|
||||
showDialog({});
|
||||
}
|
||||
function remove(row: any) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
@@ -84,6 +109,36 @@ function remove(row: any) {
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
function resetPassword(row: any) {
|
||||
ElMessageBox.confirm(
|
||||
$t('sysAccount.resetPasswordConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true;
|
||||
api
|
||||
.post('/api/v1/sysAccount/resetPassword', { id: row.id })
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
|
||||
done();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
instance.confirmButtonLoading = false;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
},
|
||||
).catch(() => {});
|
||||
}
|
||||
function isAdmin(data: any) {
|
||||
return data?.accountType === 1 || data?.accountType === 99;
|
||||
}
|
||||
@@ -91,13 +146,14 @@ function isAdmin(data: any) {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<SysAccountImportModal ref="importDialog" @reload="reset" />
|
||||
<SysAccountModal ref="saveDialog" @reload="reset" />
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="showDialog({})"
|
||||
@button-click="handleHeaderButtonClick"
|
||||
/>
|
||||
</template>
|
||||
<PageData
|
||||
@@ -193,6 +249,13 @@ function isAdmin(data: any) {
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-access:code="'/api/v1/sysAccount/save'">
|
||||
<ElDropdownItem @click="resetPassword(row)">
|
||||
<ElButton type="primary" :icon="Lock" link>
|
||||
{{ $t('sysAccount.resetPassword') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
<div v-access:code="'/api/v1/sysAccount/remove'">
|
||||
<ElDropdownItem @click="remove(row)">
|
||||
<ElButton type="danger" :icon="Delete" link>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
|
||||
|
||||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
// import Cropper from '#/components/upload/Cropper.vue';
|
||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
// vue
|
||||
@@ -23,21 +24,36 @@ const saveForm = ref<FormInstance>();
|
||||
// variables
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const entity = ref<any>({
|
||||
deptId: '',
|
||||
loginName: '',
|
||||
password: '',
|
||||
accountType: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
dataScope: '',
|
||||
deptIdList: '',
|
||||
status: '',
|
||||
remark: '',
|
||||
positionIds: [],
|
||||
});
|
||||
function createDefaultEntity() {
|
||||
return {
|
||||
deptId: '',
|
||||
loginName: '',
|
||||
password: '',
|
||||
accountType: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
dataScope: '',
|
||||
deptIdList: '',
|
||||
status: 1,
|
||||
remark: '',
|
||||
positionIds: [],
|
||||
roleIds: [],
|
||||
};
|
||||
}
|
||||
const entity = ref<any>(createDefaultEntity());
|
||||
const validateStrongPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error($t('message.required')));
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(value)) {
|
||||
callback(new Error($t('sysAccount.passwordStrongTip')));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
const btnLoading = ref(false);
|
||||
const rules = ref({
|
||||
deptId: [
|
||||
@@ -50,7 +66,7 @@ const rules = ref({
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
@@ -58,10 +74,17 @@ const rules = ref({
|
||||
});
|
||||
// functions
|
||||
function openDialog(row: any) {
|
||||
if (row.id) {
|
||||
isAdd.value = false;
|
||||
isAdd.value = !row?.id;
|
||||
entity.value = {
|
||||
...createDefaultEntity(),
|
||||
...row,
|
||||
};
|
||||
if (!Array.isArray(entity.value.roleIds)) {
|
||||
entity.value.roleIds = [];
|
||||
}
|
||||
if (!Array.isArray(entity.value.positionIds)) {
|
||||
entity.value.positionIds = [];
|
||||
}
|
||||
entity.value = row;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function save() {
|
||||
@@ -90,7 +113,7 @@ function save() {
|
||||
function closeDialog() {
|
||||
saveForm.value?.resetFields();
|
||||
isAdd.value = true;
|
||||
entity.value = {};
|
||||
entity.value = createDefaultEntity();
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -129,7 +152,15 @@ function closeDialog() {
|
||||
prop="password"
|
||||
:label="$t('sysAccount.password')"
|
||||
>
|
||||
<ElInput v-model.trim="entity.password" />
|
||||
<div class="w-full">
|
||||
<EasyFlowInputPassword
|
||||
v-model="entity.password"
|
||||
password-strength
|
||||
/>
|
||||
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('sysAccount.passwordStrongTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
||||
<ElInput v-model.trim="entity.nickname" />
|
||||
|
||||
@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
|
||||
* accessToken
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* 是否需要重置密码
|
||||
*/
|
||||
passwordResetRequired?: boolean;
|
||||
}
|
||||
|
||||
export type { UserInfo };
|
||||
|
||||
@@ -10,6 +10,7 @@ export namespace AuthApi {
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
forceChangePassword?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
|
||||
* 获取用户信息
|
||||
*/
|
||||
export async function getUserInfoApi() {
|
||||
return requestClient.get<UserInfo>('/api/v1/sysAccount/myProfile');
|
||||
return requestClient.get<UserInfo>('/userCenter/sysAccount/myProfile');
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@
|
||||
"newPwd": "NewPassword",
|
||||
"confirmPwd": "ConfirmPassword",
|
||||
"repeatPwd": "Please confirm your password again",
|
||||
"notSamePwd": "The two passwords are inconsistent"
|
||||
"notSamePwd": "The two passwords are inconsistent",
|
||||
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages."
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@
|
||||
"newPwd": "新密码",
|
||||
"confirmPwd": "确认密码",
|
||||
"repeatPwd": "请再次输入密码",
|
||||
"notSamePwd": "两次输入的密码不一致"
|
||||
"notSamePwd": "两次输入的密码不一致",
|
||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { startProgress, stopProgress } from '@easyflow/utils';
|
||||
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
isForcePasswordRoute,
|
||||
notifyForcePasswordChange,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
|
||||
@@ -53,6 +59,10 @@ function setupAccessGuard(router: Router) {
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(currentUser)) {
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
return decodeURIComponent(
|
||||
(to.query?.redirect as string) ||
|
||||
userStore.userInfo?.homePath ||
|
||||
@@ -85,6 +95,14 @@ function setupAccessGuard(router: Router) {
|
||||
return to;
|
||||
}
|
||||
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||
if (from.name) {
|
||||
notifyForcePasswordChange();
|
||||
}
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
@@ -92,7 +110,6 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 生成路由表
|
||||
// 当前登录用户拥有的角色标识列表
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
const userRoles = userInfo.roles ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
|
||||
@@ -12,6 +12,10 @@ import { defineStore } from 'pinia';
|
||||
|
||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
@@ -20,6 +24,55 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
async function finalizeLogin(
|
||||
accessToken: string,
|
||||
forceChangePassword?: boolean,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
let userInfo: null | UserInfo = null;
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
const forcePasswordChange = shouldForcePasswordChange(
|
||||
userInfo,
|
||||
forceChangePassword,
|
||||
);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
}
|
||||
|
||||
if (forcePasswordChange) {
|
||||
await router.push(buildForcePasswordRoute());
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
ElNotification({
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
title: $t('authentication.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
@@ -29,52 +82,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { token: accessToken } = await loginApi(params);
|
||||
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
// 将 accessToken 存储到 accessStore 中
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
// 获取用户信息并存储到 accessStore 中
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
ElNotification({
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
title: $t('authentication.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
return await finalizeLogin(accessToken, forceChangePassword, onSuccess);
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
userInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const strongPasswordPattern =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||
|
||||
export function isStrongPassword(value?: string) {
|
||||
return !!value && strongPasswordPattern.test(value);
|
||||
}
|
||||
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||
|
||||
let lastForcePasswordNoticeAt = 0;
|
||||
|
||||
export function buildForcePasswordRoute() {
|
||||
return {
|
||||
name: 'Profile',
|
||||
query: {
|
||||
force: '1',
|
||||
tab: 'password',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isForcePasswordRoute(
|
||||
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||
) {
|
||||
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||
}
|
||||
|
||||
export function shouldForcePasswordChange(
|
||||
userInfo?: null | Record<string, any>,
|
||||
forceChangePassword?: boolean,
|
||||
) {
|
||||
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||
}
|
||||
|
||||
export function resolveForcePasswordPath(router: Router) {
|
||||
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||
}
|
||||
|
||||
export function notifyForcePasswordChange() {
|
||||
const now = Date.now();
|
||||
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
lastForcePasswordNoticeAt = now;
|
||||
ElMessage({
|
||||
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||
type: 'warning',
|
||||
duration: 2200,
|
||||
grouping: true,
|
||||
plain: true,
|
||||
showClose: true,
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Profile } from '@easyflow/common-ui';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
const forcePasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const tabs = computed(() => {
|
||||
if (forcePasswordChange.value) {
|
||||
return [
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||
() => {
|
||||
if (forcePasswordChange.value) {
|
||||
tabsValue.value = 'password';
|
||||
return;
|
||||
}
|
||||
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
|
||||
@@ -2,48 +2,75 @@
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
|
||||
const profilePasswordSettingRef = ref();
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const isForcedPasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
fieldName: 'password',
|
||||
label: $t('sysAccount.oldPwd'),
|
||||
component: 'EasyFlowInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
label: $t('sysAccount.newPwd'),
|
||||
component: 'EasyFlowInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||
},
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||
};
|
||||
},
|
||||
rules: z
|
||||
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.refine((value) => isStrongPassword(value), {
|
||||
message: $t('sysAccount.passwordStrongTip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
label: $t('sysAccount.confirmPwd'),
|
||||
component: 'EasyFlowInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
placeholder: $t('sysAccount.repeatPwd'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ required_error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.string({ required_error: $t('sysAccount.repeatPwd') })
|
||||
.min(1, { message: $t('sysAccount.repeatPwd') })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
message: $t('sysAccount.notSamePwd'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
@@ -52,12 +79,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
ElMessage.success('密码修改成功');
|
||||
const updateLoading = ref(false);
|
||||
async function handleSubmit(values: any) {
|
||||
updateLoading.value = true;
|
||||
try {
|
||||
const res = await api.post('/userCenter/sysAccount/updatePassword', values);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.success'));
|
||||
const userInfo = await authStore.fetchUserInfo();
|
||||
if (isForcedPasswordChange.value) {
|
||||
await router.replace(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
updateLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
:button-loading="updateLoading"
|
||||
:button-text="$t('button.update')"
|
||||
ref="profilePasswordSettingRef"
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
|
||||
@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
|
||||
* accessToken
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* 是否需要重置密码
|
||||
*/
|
||||
passwordResetRequired?: boolean;
|
||||
}
|
||||
|
||||
export type { UserInfo };
|
||||
|
||||
@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
|
||||
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
||||
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
||||
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
||||
`password_reset_required` tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码',
|
||||
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
||||
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
||||
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
||||
|
||||
@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
|
||||
-- ----------------------------
|
||||
-- Records of tb_sys_account
|
||||
-- ----------------------------
|
||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `password_reset_required`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 0, 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of tb_sys_account_role
|
||||
|
||||
2
sql/05-easyflow-v2.p2-account-password-reset.sql
Normal file
2
sql/05-easyflow-v2.p2-account-password-reset.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tb_sys_account
|
||||
ADD COLUMN password_reset_required tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码' AFTER password;
|
||||
@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
|
||||
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
||||
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
||||
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
||||
`password_reset_required` tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码',
|
||||
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
||||
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
||||
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
||||
|
||||
@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
|
||||
-- ----------------------------
|
||||
-- Records of tb_sys_account
|
||||
-- ----------------------------
|
||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `password_reset_required`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 0, 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of tb_sys_account_role
|
||||
|
||||
Reference in New Issue
Block a user