d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
361 lines
9.3 KiB
Vue
361 lines
9.3 KiB
Vue
<template>
|
|
<el-dialog
|
|
append-to="body"
|
|
:title="TEXT.modules.adminUsers.resetTitle"
|
|
width="540px"
|
|
v-model="visibleProxy"
|
|
:close-on-click-modal="false"
|
|
class="reset-password-dialog"
|
|
>
|
|
<!-- 警告提示 -->
|
|
<div class="reset-warning">
|
|
<div class="warning-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
</svg>
|
|
</div>
|
|
<div class="warning-body">
|
|
<span class="warning-title">{{ TEXT.modules.adminUsers.resetWarningTitle }}</span>
|
|
<span class="warning-desc">{{ TEXT.modules.adminUsers.resetWarningDesc }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 目标用户 -->
|
|
<div class="target-user">
|
|
<div class="target-avatar">
|
|
{{ getInitials(user?.full_name || user?.username || '') }}
|
|
</div>
|
|
<div class="target-info">
|
|
<span class="target-name">{{ user?.full_name || user?.username }}</span>
|
|
<span class="target-email">{{ user?.email }}</span>
|
|
</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.emergencyPassword" prop="manualPassword">
|
|
<el-input
|
|
v-model="form.manualPassword"
|
|
type="password"
|
|
show-password
|
|
autocomplete="new-password"
|
|
name="ctms-reset-manual-password"
|
|
:placeholder="TEXT.modules.adminUsers.emergencyPasswordHint"
|
|
/>
|
|
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<template #footer>
|
|
<div class="dialog-footer">
|
|
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
|
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
{{ TEXT.modules.adminUsers.resetConfirm }}
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<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";
|
|
|
|
const props = defineProps<{
|
|
visible: boolean;
|
|
user: UserInfo | null;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "update:visible", value: boolean): void;
|
|
(e: "reset"): void;
|
|
}>();
|
|
|
|
const visibleProxy = computed({
|
|
get: () => props.visible,
|
|
set: (val: boolean) => emit("update:visible", val),
|
|
});
|
|
|
|
const formRef = ref<FormInstance>();
|
|
const submitting = ref(false);
|
|
const sendingCode = ref(false);
|
|
const form = reactive({
|
|
confirmUsername: "",
|
|
manualPassword: "",
|
|
});
|
|
|
|
const getInitials = (name: string) => {
|
|
if (!name) return '?';
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
return name.slice(0, 2).toUpperCase();
|
|
};
|
|
|
|
const rules = reactive<FormRules>({
|
|
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
|
|
manualPassword: [
|
|
{
|
|
validator: (_rule, _value, callback) => {
|
|
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",
|
|
},
|
|
],
|
|
});
|
|
|
|
const clearPasswordValidation = () => {
|
|
formRef.value?.clearValidate(["manualPassword"]);
|
|
};
|
|
|
|
watch(
|
|
() => props.visible,
|
|
(val) => {
|
|
if (val) {
|
|
form.confirmUsername = "";
|
|
form.manualPassword = "";
|
|
clearPasswordValidation();
|
|
}
|
|
}
|
|
);
|
|
|
|
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username && !!form.manualPassword);
|
|
|
|
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;
|
|
await formRef.value?.validate();
|
|
if (form.confirmUsername !== props.user.username) {
|
|
ElMessage.error(TEXT.modules.adminUsers.confirmMismatch);
|
|
return;
|
|
}
|
|
const ok = await ElMessageBox.confirm(
|
|
TEXT.modules.adminUsers.emergencyResetConfirmPrompt,
|
|
TEXT.modules.adminUsers.resetConfirmTitle,
|
|
{
|
|
type: "warning",
|
|
confirmButtonText: TEXT.modules.adminUsers.resetConfirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
}
|
|
).catch(() => null);
|
|
if (!ok) return;
|
|
submitting.value = true;
|
|
try {
|
|
await updateUser(props.user.id, { password: form.manualPassword });
|
|
ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
|
|
emit("reset");
|
|
visibleProxy.value = false;
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.resetFailed);
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.reset-warning {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
border-radius: 10px;
|
|
background: #fef3e2;
|
|
border: 1px solid #fde68a;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.warning-icon {
|
|
flex-shrink: 0;
|
|
color: var(--ctms-warning);
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.warning-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.warning-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #92400e;
|
|
}
|
|
|
|
.warning-desc {
|
|
font-size: 12px;
|
|
color: #a16207;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.target-user {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
border-radius: 10px;
|
|
background: var(--ctms-neutral-100);
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.target-avatar {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 10px;
|
|
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.target-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.target-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--ctms-text-main);
|
|
}
|
|
|
|
.target-email {
|
|
font-size: 12px;
|
|
color: var(--ctms-text-secondary);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.self-service-copy {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.self-service-title {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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 {
|
|
font-size: 12px;
|
|
color: var(--ctms-text-secondary);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.dialog-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
</style>
|