完善邮件验证与密码重置安全流程

This commit is contained in:
Cheng Zhou
2026-06-30 09:44:24 +08:00
parent 6c2bcc59b2
commit b25055775e
27 changed files with 4437 additions and 526 deletions
File diff suppressed because it is too large Load Diff
+96 -109
View File
@@ -33,59 +33,32 @@
</div>
</div>
<div class="self-service-panel">
<div class="self-service-copy">
<span class="self-service-title">{{ TEXT.modules.adminUsers.selfServiceResetTitle }}</span>
<span class="self-service-desc">{{ TEXT.modules.adminUsers.selfServiceResetDesc }}</span>
</div>
<el-button class="reset-code-send-btn" type="primary" :loading="sendingCode" @click="sendResetCode">
{{ TEXT.modules.adminUsers.sendResetCode }}
</el-button>
</div>
<div class="emergency-divider">
<span>{{ TEXT.modules.adminUsers.emergencyResetDivider }}</span>
</div>
<el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form">
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
<div class="mode-cards">
<div class="mode-card" :class="{ active: form.mode === 'auto' }" @click="form.mode = 'auto'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordAuto }}</span>
</div>
<div class="mode-card" :class="{ active: form.mode === 'manual' }" @click="form.mode = 'manual'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordManual }}</span>
</div>
</div>
</el-form-item>
<el-form-item
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
:prop="form.mode === 'auto' ? 'tempPassword' : 'manualPassword'"
>
<el-form-item :label="TEXT.modules.adminUsers.emergencyPassword" prop="manualPassword">
<el-input
v-if="form.mode === 'auto'"
v-model="form.tempPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-temp-password"
readonly
>
<template #append>
<el-button @click="regenerate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ TEXT.modules.adminUsers.regenerate }}
</el-button>
</template>
</el-input>
<el-input
v-else
v-model="form.manualPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
:placeholder="TEXT.modules.adminUsers.emergencyPasswordHint"
/>
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item>
@@ -109,6 +82,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { sendPasswordResetLink } from "../../api/auth";
import { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
@@ -130,10 +104,9 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>();
const submitting = ref(false);
const sendingCode = ref(false);
const form = reactive({
confirmUsername: "",
mode: "auto",
tempPassword: "",
manualPassword: "",
});
@@ -146,30 +119,17 @@ const getInitials = (name: string) => {
const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
tempPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) {
callback(new Error(TEXT.modules.adminUsers.tempPasswordError));
return;
}
if (form.mode === "manual" && !form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
callback();
},
trigger: "blur",
},
],
manualPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "manual" && !form.manualPassword) {
if (!form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(form.manualPassword)) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRule));
return;
}
callback();
},
trigger: "blur",
@@ -177,19 +137,8 @@ const rules = reactive<FormRules>({
],
});
const generatePassword = () => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*";
let pwd = "";
for (let i = 0; i < 12; i++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
}
form.tempPassword = pwd;
};
const regenerate = () => generatePassword();
const clearPasswordValidation = () => {
formRef.value?.clearValidate(["tempPassword", "manualPassword"]);
formRef.value?.clearValidate(["manualPassword"]);
};
watch(
@@ -197,20 +146,26 @@ watch(
(val) => {
if (val) {
form.confirmUsername = "";
form.mode = "auto";
generatePassword();
form.manualPassword = "";
clearPasswordValidation();
}
}
);
watch(
() => form.mode,
() => { clearPasswordValidation(); }
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username && !!form.manualPassword);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
const sendResetCode = async () => {
if (!props.user?.email) return;
sendingCode.value = true;
try {
await sendPasswordResetLink(props.user.email);
ElMessage.success(TEXT.modules.adminUsers.sendResetCodeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.modules.adminUsers.sendResetCodeFailed);
} finally {
sendingCode.value = false;
}
};
const onSubmit = async () => {
if (!props.user) return;
@@ -220,7 +175,7 @@ const onSubmit = async () => {
return;
}
const ok = await ElMessageBox.confirm(
TEXT.modules.adminUsers.resetConfirmPrompt,
TEXT.modules.adminUsers.emergencyResetConfirmPrompt,
TEXT.modules.adminUsers.resetConfirmTitle,
{
type: "warning",
@@ -231,8 +186,7 @@ const onSubmit = async () => {
if (!ok) return;
submitting.value = true;
try {
const password = form.mode === "manual" ? form.manualPassword : form.tempPassword;
await updateUser(props.user.id, { password });
await updateUser(props.user.id, { password: form.manualPassword });
ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
emit("reset");
visibleProxy.value = false;
@@ -319,44 +273,77 @@ const onSubmit = async () => {
color: var(--ctms-text-secondary);
}
.reset-form :deep(.el-form-item) {
.self-service-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
margin-bottom: 16px;
}
.mode-cards {
display: flex;
gap: 10px;
width: 100%;
}
.mode-card {
flex: 1;
.self-service-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border-radius: 10px;
border: 2px solid var(--ctms-border-color);
cursor: pointer;
transition: var(--ctms-transition);
color: var(--ctms-text-secondary);
gap: 3px;
min-width: 0;
}
.mode-card:hover {
border-color: var(--ctms-border-color-hover);
background: var(--ctms-neutral-100);
.self-service-title {
font-size: 13px;
font-weight: 700;
color: #1e40af;
}
.mode-card.active {
.self-service-desc {
font-size: 12px;
line-height: 1.5;
color: #3b5f9a;
}
.reset-code-send-btn {
flex: 0 0 auto;
min-width: 118px;
height: 38px;
color: #fff;
background: var(--ctms-primary);
border-color: var(--ctms-primary);
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.mode-card span {
.reset-code-send-btn:hover,
.reset-code-send-btn:focus {
color: #fff;
background: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
.reset-code-send-btn :deep(span) {
color: inherit;
}
.emergency-divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--ctms-text-tertiary);
font-size: 12px;
font-weight: 600;
margin: 2px 0 16px;
}
.emergency-divider::before,
.emergency-divider::after {
content: "";
height: 1px;
flex: 1;
background: var(--ctms-border-color);
}
.reset-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.password-hint {