完善邮件验证与密码重置安全流程
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user