fix: polish authentication and responsive controls

This commit is contained in:
2026-08-05 11:43:43 +08:00
parent f6e36d737d
commit 1245a8ec1e
9 changed files with 102 additions and 29 deletions
+6 -1
View File
@@ -481,7 +481,10 @@ def login():
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha()) if request.method == "GET": return render_auth_template("register", captcha=refresh_captcha())
username, email, password = request.form.get("username", "").strip(), normal_email(request.form.get("email", "")), request.form.get("password", "") username = request.form.get("username", "").strip()
email = normal_email(request.form.get("email", ""))
password = request.form.get("password", "")
confirmation = request.form.get("password_confirm", "")
if not captcha_is_valid(): if not captcha_is_valid():
return render_auth_error("register", "图形验证码错误", auth_error_field="captcha") return render_auth_error("register", "图形验证码错误", auth_error_field="captcha")
if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403) if not registration_allowed(): return render_auth_error("register", "当前未开放自助注册,请联系管理员。", 403)
@@ -491,6 +494,8 @@ def register():
return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email") return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email")
if not valid_password(password): if not valid_password(password):
return render_auth_error("register", PASSWORD_RULE_MESSAGE, auth_error_field="password") return render_auth_error("register", PASSWORD_RULE_MESSAGE, auth_error_field="password")
if password != confirmation:
return render_auth_error("register", "两次密码输入不一致。", auth_error_field="password_confirm")
if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用") if User.query.filter((User.username == username) | (User.email == email)).first(): return render_auth_error("register", "显示名或邮箱已被使用")
user = User(username=username, email=email, is_admin=False, is_active_account=False) user = User(username=username, email=email, is_admin=False, is_active_account=False)
user.set_password(password); db.session.add(user); db.session.commit() user.set_password(password); db.session.add(user); db.session.commit()
+1 -1
View File
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -6,15 +6,17 @@
<span class="font-bold text-textMain">{{ pagination.page }}</span> / {{ pagination.pages }} 页 <span class="font-bold text-textMain">{{ pagination.page }}</span> / {{ pagination.pages }} 页
</div> </div>
{% if pagination.pages > 1 %} {% if pagination.pages > 1 %}
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1"> <div class="flex w-full min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:w-auto">
{% set prev_page = pagination.prev_num if pagination.has_prev else pagination.page %} {% set prev_page = pagination.prev_num if pagination.has_prev else pagination.page %}
<a <a
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_prev }}" class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_prev }}"
href="{{ url_for(endpoint, page=prev_page) }}" href="{{ url_for(endpoint, page=prev_page) }}"
aria-disabled="{{ 'false' if pagination.has_prev else 'true' }}" aria-disabled="{{ 'false' if pagination.has_prev else 'true' }}"
aria-label="上一页"
title="上一页"
> >
<span class="material-symbols-outlined text-lg">chevron_left</span> <span class="material-symbols-outlined text-lg">chevron_left</span>
上一页 <span class="hidden sm:inline">上一页</span>
</a> </a>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% for page in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %} {% for page in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %}
@@ -34,8 +36,10 @@
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_next }}" class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_next }}"
href="{{ url_for(endpoint, page=next_page) }}" href="{{ url_for(endpoint, page=next_page) }}"
aria-disabled="{{ 'false' if pagination.has_next else 'true' }}" aria-disabled="{{ 'false' if pagination.has_next else 'true' }}"
aria-label="下一页"
title="下一页"
> >
下一页 <span class="hidden sm:inline">下一页</span>
<span class="material-symbols-outlined text-lg">chevron_right</span> <span class="material-symbols-outlined text-lg">chevron_right</span>
</a> </a>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "auth_base.html" %}
{% block title %}接受注册邀请{% endblock %} {% block title %}接受注册邀请{% endblock %}
{% block content %} {% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6"> <section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
+8 -6
View File
@@ -10,7 +10,8 @@
<p class="mt-2 text-sm text-textSub">管理用户账号、注册状态和预测记录。</p> <p class="mt-2 text-sm text-textSub">管理用户账号、注册状态和预测记录。</p>
</div> </div>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel"> <div class="mt-6 grid gap-6 xl:grid-cols-2">
<section class="rounded-xl border border-line bg-white p-5 shadow-panel">
<h2 class="font-extrabold">用户注册</h2> <h2 class="font-extrabold">用户注册</h2>
<form method="post" action="{{ url_for('main.update_registration_setting') }}" class="mt-3 flex items-center gap-3"> <form method="post" action="{{ url_for('main.update_registration_setting') }}" class="mt-3 flex items-center gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -20,18 +21,19 @@
</label> </label>
<button class="ui-btn ui-btn-sm ui-btn-primary">保存</button> <button class="ui-btn ui-btn-sm ui-btn-primary">保存</button>
</form> </form>
</section> </section>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel"> <section class="rounded-xl border border-line bg-white p-5 shadow-panel">
<h2 class="font-extrabold">邀请注册</h2> <h2 class="font-extrabold">邀请注册</h2>
<p class="mt-1 text-sm text-textSub">即使关闭自助注册,也可向指定邮箱发送一次性注册链接。</p> <p class="mt-1 text-sm text-textSub">即使关闭自助注册,也可向指定邮箱发送一次性注册链接。</p>
<form method="post" action="{{ url_for('main.admin_send_registration_invitation') }}" class="mt-4 flex flex-col gap-2 sm:flex-row" novalidate> <form method="post" action="{{ url_for('main.admin_send_registration_invitation') }}" class="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="sr-only" for="inviteEmail">受邀邮箱</label> <label class="sr-only" for="inviteEmail">受邀邮箱</label>
<input id="inviteEmail" name="email" type="email" required autocomplete="email" class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2 text-sm" placeholder="受邀人的邮箱地址"> <input id="inviteEmail" name="email" type="email" required autocomplete="email" class="min-w-0 w-full rounded-lg border border-line px-3 py-2 text-sm sm:w-80" placeholder="受邀人的邮箱地址">
<button class="ui-btn ui-btn-sm ui-btn-primary shrink-0">发送注册链接</button> <button class="ui-btn ui-btn-sm ui-btn-primary shrink-0">发送注册链接</button>
</form> </form>
</section> </section>
</div>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel"> <section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}账户验证{% endblock %} | 供水管道健康评估系统</title>
<meta name="theme-color" content="#005EB8">
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet">
</head>
<body class="min-h-screen bg-page text-textMain">
<main class="mx-auto flex min-h-screen max-w-md items-center px-4 py-5 sm:px-5">
{% block content %}{% endblock %}
</main>
</body>
</html>
+20
View File
@@ -143,6 +143,16 @@
</button> </button>
</div> </div>
</div> </div>
<div>
<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">lock</span>
<input id="registerPasswordConfirm" name="password_confirm" type="password" minlength="12" required data-field-label="确认密码" autocomplete="new-password" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-12 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="请再次输入密码" />
<button class="password-toggle" type="button" data-password-toggle="registerPasswordConfirm" aria-label="显示确认密码" aria-pressed="false" tabindex="-1" {{ 'disabled' if not allow_registration }}>
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</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="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
@@ -327,6 +337,16 @@
showAppNotification(`密码至少需要 ${minLength}`, 'error', undefined, shortPassword); showAppNotification(`密码至少需要 ${minLength}`, 'error', undefined, shortPassword);
markFieldError(shortPassword); markFieldError(shortPassword);
shortPassword.focus(); shortPassword.focus();
return;
}
const password = form.querySelector('[name="password"]');
const confirmation = form.querySelector('[name="password_confirm"]');
if (password && confirmation && password.value !== confirmation.value) {
event.preventDefault();
showAppNotification('两次密码输入不一致', 'error', undefined, confirmation);
markFieldError(confirmation);
confirmation.focus();
} }
}); });
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "auth_base.html" %}
{% block title %}重置密码{% endblock %} {% block title %}重置密码{% endblock %}
{% block content %} {% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6"> <section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
+27 -1
View File
@@ -160,7 +160,7 @@ class EmailAuthenticationTest(unittest.TestCase):
app = self.create_app(directory); client = app.test_client() app = self.create_app(directory); client = app.test_client()
page = client.get("/register") page = client.get("/register")
with client.session_transaction() as state: captcha = state["captcha"] with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "Password-1234!", "captcha": captcha}) response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "Password-1234!", "password_confirm": "Password-1234!", "captcha": captcha})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): with app.app_context():
user = User.query.filter_by(email="alice@example.com").one() user = User.query.filter_by(email="alice@example.com").one()
@@ -175,6 +175,31 @@ class EmailAuthenticationTest(unittest.TestCase):
self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account) self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account)
self.assertEqual(TrustedDevice.query.count(), 0) self.assertEqual(TrustedDevice.query.count(), 0)
def test_registration_rejects_mismatched_password_confirmation(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
client = app.test_client()
page = client.get("/register")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post(
"/register",
data={
"csrf_token": self.csrf(page),
"username": "Alice",
"email": "alice@example.com",
"password": "Password-1234!",
"password_confirm": "Different-password-1234!",
"captcha": captcha,
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("两次密码输入不一致", response.get_data(as_text=True))
with app.app_context():
self.assertEqual(User.query.count(), 0)
@patch("app.routes.send_transactional_email") @patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456) @patch("app.routes.secrets.randbelow", return_value=123456)
def test_login_accepts_username_or_email(self, _random, _send): def test_login_accepts_username_or_email(self, _random, _send):
@@ -312,6 +337,7 @@ class EmailAuthenticationTest(unittest.TestCase):
html = send.call_args.kwargs["html"] html = send.call_args.kwargs["html"]
reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path
page = client.get(reset_path) page = client.get(reset_path)
self.assertNotIn(b"<header", page.data)
response = client.post(reset_path, data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"}) response = client.post(reset_path, data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): with app.app_context():