feat: refine email verification login flow

This commit is contained in:
2026-08-04 18:24:05 +08:00
parent 732c1df031
commit 87c792db44
7 changed files with 323 additions and 80 deletions
+45 -5
View File
@@ -35,7 +35,7 @@
{% else %}
<form method="post" class="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码">
<input name="current_password" type="password" autocomplete="current-password" required class="w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码">
<button class="ui-btn ui-btn-primary w-full sm:w-fit">发送密码验证码</button>
</form>
{% endif %}
@@ -49,11 +49,12 @@
<p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p>
</div>
</div>
<form method="post" class="mt-5 grid gap-3 sm:grid-cols-2">
<form method="post" class="mt-5 grid gap-3 sm:grid-cols-2" novalidate data-change-email-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="change_email">
<input name="current_password" type="password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="输入当前密码">
<input name="new_email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新登录邮箱">
<input name="current_password" type="password" autocomplete="current-password" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="输入当前密码">
<input name="new_email" type="email" autocomplete="email" required aria-describedby="changeEmailFormError" class="w-full rounded-lg border border-line px-3 py-2" placeholder="新登录邮箱">
<p id="changeEmailFormError" class="hidden text-sm text-dangerText sm:col-span-2" role="alert"></p>
<button class="ui-btn ui-btn-secondary w-full sm:col-span-2 sm:w-fit">验证并更换邮箱</button>
</form>
</section>
@@ -69,10 +70,49 @@
<form method="post" class="mt-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="revoke_devices">
<input name="current_password" type="password" required class="mb-3 w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码以确认">
<input name="current_password" type="password" autocomplete="current-password" required class="mb-3 w-full rounded-lg border border-line px-3 py-2 sm:max-w-sm" placeholder="输入当前密码以确认">
<button class="ui-btn ui-btn-secondary w-full sm:w-fit">撤销所有受信设备</button>
</form>
</section>
</div>
</section>
<script>
(() => {
const form = document.querySelector('[data-change-email-form]');
if (!form) return;
const password = form.querySelector('input[name="current_password"]');
const email = form.querySelector('input[name="new_email"]');
const error = document.getElementById('changeEmailFormError');
const fields = [password, email];
function clearError() {
error.textContent = '';
error.classList.add('hidden');
fields.forEach((field) => {
field.classList.remove('border-red-300');
field.removeAttribute('aria-invalid');
});
}
function showError(field, message) {
error.textContent = message;
error.classList.remove('hidden');
field.classList.add('border-red-300');
field.setAttribute('aria-invalid', 'true');
field.focus();
}
fields.forEach((field) => field.addEventListener('input', clearError));
form.addEventListener('submit', (event) => {
clearError();
if (!password.value) {
event.preventDefault();
showError(password, '请输入当前密码。');
} else if (!email.value || !email.validity.valid) {
event.preventDefault();
showError(email, '请输入有效的新登录邮箱。');
}
});
})();
</script>
{% endblock %}
+17 -18
View File
@@ -14,6 +14,7 @@
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
window.__pageNotice = {{ page_notice|tojson }};
window.__authErrorField = {{ auth_error_field|tojson }};
</script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
@@ -70,10 +71,10 @@
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">邮箱</label>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名或邮箱</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">mail</span>
<input name="email" type="email" required data-field-label="邮箱" autocomplete="email" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入邮箱" />
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</span>
<input name="identifier" required data-field-label="用户名或邮箱" autocomplete="username" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入用户名或邮箱" />
</div>
</div>
@@ -92,14 +93,14 @@
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">图形验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入验证码" />
<input name="captcha" required data-field-label="图形验证码" class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入图形验证码" />
</div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.login') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新验证码">
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="图形验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.login') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新图形验证码">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
@@ -143,14 +144,14 @@
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">验证码</label>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">图形验证码</label>
<div class="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required data-field-label="验证码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入验证码" />
<input name="captcha" required data-field-label="图形验证码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入图形验证码" />
</div>
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.register') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0 {{ 'pointer-events-none opacity-50' if not allow_registration }}" aria-label="刷新验证码" aria-disabled="{{ 'true' if not allow_registration else 'false' }}">
<div class="captcha-token rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic" aria-label="图形验证码" data-captcha-token>{{ captcha }}</div>
<a href="{{ url_for('main.register') }}" tabindex="-1" class="ui-btn ui-btn-field ui-btn-secondary px-0 {{ 'pointer-events-none opacity-50' if not allow_registration }}" aria-label="刷新图形验证码" aria-disabled="{{ 'true' if not allow_registration else 'false' }}">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
@@ -277,21 +278,19 @@
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
}
function fieldForServerMessage(message) {
function fieldForServerMessage() {
const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null;
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]');
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
if (message.includes('邮箱')) return activeForm.querySelector('input[name="email"]');
if (message.includes('用户名') || message.includes('显示名')) return activeForm.querySelector('input[name="username"]');
return null;
return window.__authErrorField
? activeForm.querySelector(`[name="${window.__authErrorField}"]`)
: null;
}
const flashedMessages = window.__flashMessages || [];
const pageNotice = window.__pageNotice;
if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1];
const serverField = category === 'error' ? fieldForServerMessage(message) : null;
const serverField = category === 'error' ? fieldForServerMessage() : null;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') {
markFieldError(serverField);
+16 -4
View File
@@ -13,11 +13,11 @@
<section class="w-full rounded-2xl border border-line bg-white p-5 shadow-panel sm:p-7">
<span class="material-symbols-outlined text-4xl text-primary">mark_email_read</span>
<h1 class="mt-3 text-2xl font-extrabold">{{ title }}</h1>
<p class="mt-2 text-sm leading-6 text-textSub">验证码已发送至 {{ email }},10 分钟内有效。</p>
<p class="mt-2 text-sm leading-6 text-textSub">邮箱验证码已发送至 {{ email }},10 分钟内有效。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="mt-4 rounded-lg bg-dangerSoft p-3 text-sm text-dangerText">{{ message }}</p>
<p class="mt-4 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primaryDeep' }}">{{ message }}</p>
{% endfor %}
{% endwith %}
@@ -25,7 +25,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input id="verificationCode" type="hidden" name="code" value="">
<fieldset>
<legend class="text-sm font-semibold">请输入 6 位验证码</legend>
<legend class="text-sm font-semibold">请输入 6 位邮箱验证码</legend>
<div class="mt-3 grid grid-cols-6 gap-1.5 sm:gap-2" id="codeInputs">
{% for index in range(6) %}
<input
@@ -33,7 +33,7 @@
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
aria-label="验证码第 {{ index + 1 }} 位"
aria-label="邮箱验证码第 {{ index + 1 }} 位"
class="h-11 min-w-0 rounded-lg border border-line text-center text-lg font-bold tracking-wide focus:border-primary focus:ring-primary sm:h-12 sm:text-xl"
data-code-digit
{% if index == 0 %}autofocus{% endif %}
@@ -41,6 +41,18 @@
{% endfor %}
</div>
</fieldset>
{% if purpose == 'login' %}
<label class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-blue-100 bg-blueSoft px-4 py-3.5 transition hover:border-primary hover:bg-blue-50/50">
<input name="trust_device" type="checkbox" class="mt-1 h-4 w-4 shrink-0 rounded border-slate-300 text-primary focus:ring-primary">
<span class="min-w-0">
<span class="flex items-center gap-2 font-bold text-textMain">
<span class="material-symbols-outlined text-lg text-primary" aria-hidden="true">devices</span>
信任此设备
</span>
<span class="mt-1 block text-sm leading-5 text-textSub">30 天内登录无需邮箱二次认证。</span>
</span>
</label>
{% endif %}
<button id="verifyButton" class="ui-btn ui-btn-primary mt-6 w-full" type="submit">验证并继续</button>
</form>