发布 v1.10 #5
@@ -203,11 +203,17 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定账号密码重置为系统默认强密码。
|
||||
*
|
||||
* @param id 账号 ID
|
||||
* @return 本次重置后使用的明文密码
|
||||
*/
|
||||
@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();
|
||||
public Result<String> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||
String password = service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||
return Result.ok(password);
|
||||
}
|
||||
|
||||
@PostMapping("/removeBatchWithResult")
|
||||
|
||||
@@ -32,7 +32,14 @@ public interface SysAccountService extends IService<SysAccount> {
|
||||
|
||||
SysAccount getByUsername(String userKey);
|
||||
|
||||
void resetPassword(BigInteger accountId, BigInteger operatorId);
|
||||
/**
|
||||
* 将账号密码重置为系统默认强密码。
|
||||
*
|
||||
* @param accountId 账号 ID
|
||||
* @param operatorId 操作人账号 ID
|
||||
* @return 本次重置后使用的明文密码
|
||||
*/
|
||||
String resetPassword(BigInteger accountId, BigInteger operatorId);
|
||||
|
||||
SysAccountBatchActionResultVo removeBatchWithResult(Collection<BigInteger> ids);
|
||||
|
||||
|
||||
@@ -231,21 +231,30 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
return getOne(w);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将账号密码重置为系统默认强密码。
|
||||
*
|
||||
* @param accountId 账号 ID
|
||||
* @param operatorId 操作人账号 ID
|
||||
* @return 本次重置后使用的明文密码
|
||||
*/
|
||||
@Override
|
||||
public void resetPassword(BigInteger accountId, BigInteger operatorId) {
|
||||
public String resetPassword(BigInteger accountId, BigInteger operatorId) {
|
||||
SysAccount record = getById(accountId);
|
||||
if (record == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
validateResetPasswordAllowed(record);
|
||||
String plainPassword = accountSecurityProperties.getDefaultResetPassword();
|
||||
SysAccount update = new SysAccount();
|
||||
update.setId(accountId);
|
||||
update.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
|
||||
update.setPassword(BCrypt.hashpw(plainPassword));
|
||||
update.setPasswordResetRequired(true);
|
||||
update.setModified(new Date());
|
||||
update.setModifiedBy(operatorId);
|
||||
updateById(update);
|
||||
StpUtil.kickout(accountId);
|
||||
return plainPassword;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
package tech.easyflow.system.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||
import tech.easyflow.system.config.AccountSecurityProperties;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -51,4 +60,54 @@ public class SysAccountServiceImplTest {
|
||||
assertEquals("3", displayNameMap.get(BigInteger.valueOf(3)));
|
||||
verify(service, times(1)).list(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重置密码返回实际明文,同时持久化对应密文并踢出旧登录态。
|
||||
*
|
||||
* @throws Exception 注入测试配置失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnPlainPasswordAfterReset() throws Exception {
|
||||
BigInteger accountId = BigInteger.valueOf(10);
|
||||
BigInteger operatorId = BigInteger.valueOf(20);
|
||||
String expectedPassword = "!Reset123";
|
||||
SysAccountServiceImpl service = spy(new SysAccountServiceImpl());
|
||||
AccountSecurityProperties properties = new AccountSecurityProperties();
|
||||
properties.setDefaultResetPassword(expectedPassword);
|
||||
properties.afterPropertiesSet();
|
||||
setField(service, "accountSecurityProperties", properties);
|
||||
|
||||
SysAccount account = new SysAccount();
|
||||
account.setId(accountId);
|
||||
account.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||
doReturn(account).when(service).getById(accountId);
|
||||
doReturn(true).when(service).updateById(any(SysAccount.class));
|
||||
|
||||
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
||||
String actualPassword = service.resetPassword(accountId, operatorId);
|
||||
|
||||
assertEquals(expectedPassword, actualPassword);
|
||||
ArgumentCaptor<SysAccount> updateCaptor = ArgumentCaptor.forClass(SysAccount.class);
|
||||
verify(service).updateById(updateCaptor.capture());
|
||||
SysAccount update = updateCaptor.getValue();
|
||||
assertTrue(BCrypt.checkpw(expectedPassword, update.getPassword()));
|
||||
assertEquals(Boolean.TRUE, update.getPasswordResetRequired());
|
||||
assertEquals(operatorId, update.getModifiedBy());
|
||||
stp.verify(() -> StpUtil.kickout(accountId), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置测试对象的私有字段。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
* @throws Exception 字段不存在或不可访问
|
||||
*/
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = SysAccountServiceImpl.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"resetPassword": "Reset Password",
|
||||
"resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.",
|
||||
"resetPasswordSuccess": "Password has been reset to the system default strong password and must be changed on next login",
|
||||
"resetPasswordResultTitle": "Password Reset Successful",
|
||||
"resetPasswordResultDescription": "Copy the password and share it securely. The user must change it at the next sign-in.",
|
||||
"resetPasswordResultLabel": "New Password",
|
||||
"resetPasswordResultMissing": "The password was reset, but the new password was not returned. Contact an administrator.",
|
||||
"copyPassword": "Copy password",
|
||||
"batchSelectedCount": "{count} selected",
|
||||
"batchToolbarHint": "Batch actions are available for selected accounts",
|
||||
"batchActionSelectRequired": "Please select at least one account",
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"resetPassword": "重置密码",
|
||||
"resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。",
|
||||
"resetPasswordSuccess": "密码已重置为系统默认强密码,用户下次登录需修改密码",
|
||||
"resetPasswordResultTitle": "密码重置成功",
|
||||
"resetPasswordResultDescription": "请复制密码并安全地告知用户,用户下次登录时需修改密码。",
|
||||
"resetPasswordResultLabel": "新密码",
|
||||
"resetPasswordResultMissing": "密码已重置,但接口未返回新密码,请联系管理员",
|
||||
"copyPassword": "复制密码",
|
||||
"batchSelectedCount": "已选择 {count} 项",
|
||||
"batchToolbarHint": "可对选中账号执行批量操作",
|
||||
"batchActionSelectRequired": "请先选择要操作的账号",
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useDictStore } from '#/store';
|
||||
|
||||
import SysAccountImportModal from './SysAccountImportModal.vue';
|
||||
import SysAccountModal from './SysAccountModal.vue';
|
||||
import SysAccountPasswordResultModal from './SysAccountPasswordResultModal.vue';
|
||||
|
||||
onMounted(() => {
|
||||
initDict();
|
||||
@@ -40,6 +41,7 @@ onMounted(() => {
|
||||
const pageDataRef = ref();
|
||||
const tableRef = ref();
|
||||
const importDialog = ref();
|
||||
const passwordResultDialog = ref();
|
||||
const saveDialog = ref();
|
||||
const selectedRows = ref<any[]>([]);
|
||||
const batchActionLoading = ref(false);
|
||||
@@ -124,6 +126,7 @@ function remove(row: any) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
function resetPassword(row: any) {
|
||||
let plainPassword = '';
|
||||
ElMessageBox.confirm(
|
||||
$t('sysAccount.resetPasswordConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
@@ -139,7 +142,7 @@ function resetPassword(row: any) {
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
|
||||
plainPassword = typeof res.data === 'string' ? res.data : '';
|
||||
clearSelection();
|
||||
done();
|
||||
}
|
||||
@@ -152,7 +155,15 @@ function resetPassword(row: any) {
|
||||
}
|
||||
},
|
||||
},
|
||||
).catch(() => {});
|
||||
)
|
||||
.then(() => {
|
||||
if (!plainPassword) {
|
||||
ElMessage.error($t('sysAccount.resetPasswordResultMissing'));
|
||||
return;
|
||||
}
|
||||
passwordResultDialog.value?.openDialog(plainPassword);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
function handleSelectionChange(rows: any[]) {
|
||||
selectedRows.value = rows;
|
||||
@@ -260,6 +271,7 @@ function isAdmin(data: any) {
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<SysAccountImportModal ref="importDialog" @reload="reset" />
|
||||
<SysAccountModal ref="saveDialog" @reload="reset" />
|
||||
<SysAccountPasswordResultModal ref="passwordResultDialog" />
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
|
||||
import { CopyDocument } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElInput } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const password = ref('');
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (!visible) {
|
||||
password.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function openDialog(value: string) {
|
||||
password.value = value;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
function selectPassword(event: FocusEvent) {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
event.target.select();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
await copyTextWithFeedback(
|
||||
password.value,
|
||||
$t('message.copySuccess'),
|
||||
$t('message.copyFail'),
|
||||
);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowPanelModal
|
||||
v-model:open="dialogVisible"
|
||||
centered
|
||||
width="482px"
|
||||
:confirm-text="$t('message.ok')"
|
||||
:description="$t('sysAccount.resetPasswordResultDescription')"
|
||||
:show-cancel-button="false"
|
||||
:title="$t('sysAccount.resetPasswordResultTitle')"
|
||||
@confirm="closeDialog"
|
||||
>
|
||||
<div class="password-result">
|
||||
<label class="password-result__label" for="reset-password-result">
|
||||
{{ $t('sysAccount.resetPasswordResultLabel') }}
|
||||
</label>
|
||||
<ElInput
|
||||
id="reset-password-result"
|
||||
class="password-result__input"
|
||||
:model-value="password"
|
||||
readonly
|
||||
size="large"
|
||||
@focus="selectPassword"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElButton
|
||||
class="password-result__copy"
|
||||
:aria-label="$t('sysAccount.copyPassword')"
|
||||
:icon="CopyDocument"
|
||||
link
|
||||
:title="$t('sysAccount.copyPassword')"
|
||||
@click="copyPassword"
|
||||
/>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</EasyFlowPanelModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.password-result__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
:deep(.password-result__input .el-input__wrapper) {
|
||||
padding-inline: var(--space-4) var(--space-2);
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: 0 0 0 1px hsl(var(--line-subtle)) inset;
|
||||
transition:
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
:deep(.password-result__input .el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px hsl(var(--border)) inset;
|
||||
}
|
||||
|
||||
:deep(.password-result__input .el-input__wrapper.is-focus) {
|
||||
background: hsl(var(--surface-panel));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 18%) inset;
|
||||
}
|
||||
|
||||
:deep(.password-result__input .el-input__inner) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
:deep(.password-result__copy) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
color: hsl(var(--text-muted));
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
:deep(.password-result__copy:not(.is-disabled):hover),
|
||||
:deep(.password-result__copy:not(.is-disabled):focus-visible) {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user