Compare commits

...
11 Commits
34 changed files with 1537 additions and 967 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ node_modules
data data
uploads uploads
static/images static/images
pipe_survival_0331.db pipe_survival.db
app.log app.log
server.err.log server.err.log
server.out.log server.out.log
+26 -19
View File
@@ -1,42 +1,49 @@
# 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。 # 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。
# 优先级:系统环境变量 > .env.local > .env。 # 配置优先级:系统环境变量 > .env.local > .env。
# 运行环境:production(生产)/ development(开发) # ==================== 运行模式 ====================
# APP_ENV:production(生产)/ development(开发)。
# DEBUG:是否开启 Flask 调试与热重载;生产必须为 false。
APP_ENV=production APP_ENV=production
# 是否开启 Flask 调试与热重载。生产必须为 false。
DEBUG=false DEBUG=false
# 会话加密密钥。执行 python -c "import secrets; print(secrets.token_hex(32))" 生成。
# ==================== 会话与基础安全 ====================
# SECRET_KEY:执行 python -c "import secrets; print(secrets.token_hex(32))" 生成。
SECRET_KEY=replace-with-a-long-random-secret SECRET_KEY=replace-with-a-long-random-secret
# ==================== 管理员初始化 ====================
# 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。 # 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-password ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_EMAIL=admin@waternetwork.cn ADMIN_EMAIL=
# 数据库连接。默认使用项目 data 目录中的 SQLite 文件。 # ==================== 数据与文件 ====================
DATABASE_URL=sqlite:////app/data/pipe_survival_0331.db # DATABASE_URL:Docker 环境下使用 /app/data 中的 SQLite 文件。
# 页面展示的时区。 DATABASE_URL=sqlite:////app/data/pipe_survival.db
# APP_TIMEZONE:页面与导出文件显示时间使用的时区。
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
# MAX_UPLOAD_BYTES:单次上传文件最大字节数,16 MiB = 16777216。
# 单个上传文件最大字节数,16 MiB = 16777216。
MAX_UPLOAD_BYTES=16777216 MAX_UPLOAD_BYTES=16777216
# 模型核心目录。 # FUSION_MODEL_CORE_DIR:Docker 中的模型核心目录。
FUSION_MODEL_CORE_DIR=/app/model_core FUSION_MODEL_CORE_DIR=/app/model_core
# 是否允许公开自助注册。
ALLOW_REGISTRATION=false
# Resend 邮件服务。发件地址必须已在 Resend 验证。 # ==================== 注册与邮件服务 ====================
# ALLOW_REGISTRATION:是否允许公开自助注册。
ALLOW_REGISTRATION=false
# Resend 邮件服务:发件地址必须已在 Resend 验证。
RESEND_API_KEY=re_xxxxxxxxx RESEND_API_KEY=re_xxxxxxxxx
RESEND_FROM_EMAIL=no-reply@waternetwork.cn RESEND_FROM_EMAIL=no-reply@waternetwork.cn
# 邮件验证码:有效期(分钟)、重发间隔(秒)、最大尝试次数。 # ==================== 登录认证与会话期限 ====================
# 邮件验证码:有效期(分钟)、重发间隔(秒)和最大尝试次数。
EMAIL_CODE_MINUTES=10 EMAIL_CODE_MINUTES=10
EMAIL_CODE_RESEND_SECONDS=60 EMAIL_CODE_RESEND_SECONDS=60
EMAIL_CODE_MAX_ATTEMPTS=5 EMAIL_CODE_MAX_ATTEMPTS=5
# 邮箱验证码通过后,用于改密和找回密码的短时授权(分钟)。 # PASSWORD_RESET_TOKEN_MINUTES:密码重置和管理员注册链接的有效期(分钟)。
PASSWORD_RESET_TOKEN_MINUTES=10
# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。
FRESH_AUTH_MINUTES=5 FRESH_AUTH_MINUTES=5
# 受信设备的有效期(天)。 # TRUSTED_DEVICE_DAYS:受信设备有效期(天)。
TRUSTED_DEVICE_DAYS=30 TRUSTED_DEVICE_DAYS=30
# SESSION_COOKIE_SECURE:HTTPS 下必须为 true;本地 HTTP 开发环境设为 false。
# HTTPS 下必须为 true;本地 HTTP 开发环境设为 false。
SESSION_COOKIE_SECURE=true SESSION_COOKIE_SECURE=true
+1 -1
View File
@@ -25,7 +25,7 @@ server.err.log
data/ data/
uploads/ uploads/
static/images/ static/images/
pipe_survival_0331.db pipe_survival.db
# Large generated or local model artifacts # Large generated or local model artifacts
model_core/ model_core/
+1 -1
View File
@@ -37,4 +37,4 @@ RUN mkdir -p data static/images uploads
EXPOSE 5005 EXPOSE 5005
CMD ["conda", "run", "--no-capture-output", "-n", "demo", "gunicorn", "--bind", "0.0.0.0:5005", "--workers", "2", "--threads", "4", "--timeout", "120", "main:app"] CMD ["conda", "run", "--no-capture-output", "-n", "demo", "gunicorn", "--preload", "--bind", "0.0.0.0:5005", "--workers", "2", "--threads", "4", "--timeout", "120", "main:app"]
+177
View File
@@ -0,0 +1,177 @@
# 供水管道健康状态与剩余寿命评估系统
用于上传管道数据,完成健康状态和剩余寿命预测,并导出结果表格。系统包含用户认证、邮箱二次认证、管理员用户管理、邀请注册和上传记录下载等功能。
## 主要功能
- 上传 Excel 数据并执行预测,生成结果文件和图像。
- 用户使用用户名或邮箱登录,陌生设备需完成邮箱二次认证。
- 支持公开自助注册,也支持管理员在关闭公开注册后发送注册链接。
- 支持一次性密码重置链接、修改登录邮箱、受信设备管理和会话撤销。
- 管理员可搜索用户、启停账号、发送密码重置链接、撤销受信设备,以及下载上传记录。
- 上传记录和用户列表均支持分页。
## 运行要求
- Docker 和 Docker Compose,推荐用于部署。
- 本机开发建议使用 Python 3.12 和 Conda。
- 邮件认证、密码重置和邀请注册需要配置 Resend。
## 配置环境变量
复制模板创建实际配置文件:
```bash
cp .env.example .env
```
`.env` 用于实际运行配置,不应提交到版本库。`.env.local` 用于本机开发覆盖,空值表示继续使用 `.env` 中的值。优先级为:系统环境变量、`.env.local`、`.env`。
生产环境至少应配置:
```dotenv
APP_ENV=production
DEBUG=false
SECRET_KEY=请填写高强度随机密钥
ADMIN_USERNAME=admin
ADMIN_PASSWORD=请填写管理员初始密码
ADMIN_EMAIL=管理员邮箱
DATABASE_URL=sqlite:////app/data/pipe_survival.db
RESEND_API_KEY=Resend_API_密钥
RESEND_FROM_EMAIL=已验证的发件人地址
SESSION_COOKIE_SECURE=true
```
可使用下列命令生成 `SECRET_KEY`:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
其余变量和默认值见 [`.env.example`](.env.example)。其中,`EMAIL_CODE_MINUTES` 控制邮箱验证码有效期,`PASSWORD_RESET_TOKEN_MINUTES` 控制密码重置和邀请注册链接的有效期。
## Docker 部署
### 直接构建
```bash
docker compose up -d --build
docker compose logs -f
```
服务默认监听 `5005` 端口。数据通过以下目录持久化:
| 本机目录 | 容器目录 | 用途 |
| --- | --- | --- |
| `./data` | `/app/data` | SQLite 数据库和运行日志 |
| `./data/uploads` | `/app/uploads` | 已上传的原始文件与预测结果 |
| `./data/images` | `/app/static/images` | 预测图像 |
不要在更新时使用 `docker compose down -v`,否则可能删除持久化卷。数据库结构会在启动时自动补齐,不会删除现有数据。
### 导入已导出的镜像
将镜像包、`docker-compose.yml` 和 `.env` 放到同一目录后执行:
```bash
sudo docker load -i pipeline-lifetime_YYYYMMDD.N.tar
docker compose up -d
docker compose logs -f
```
更新镜像时先停止旧容器即可,保留 `data` 目录:
```bash
docker compose down
sudo docker load -i pipeline-lifetime_YYYYMMDD.N.tar
docker compose up -d
```
## 本机开发
安装 Python 依赖后,使用 `.env.local` 覆盖开发配置:
```bash
conda create -n pipeline-lifetime python=3.12 -y
conda activate pipeline-lifetime
pip install -r requirements.txt
npm install
npm run build:css
python main.py
```
访问 `http://127.0.0.1:5005`。本机 HTTP 开发时请将 `SESSION_COOKIE_SECURE=false`。修改模板中的 Tailwind 类后,需要执行 `npm run build:css`;持续开发可使用:
```bash
npm run watch:css
```
也可使用 Docker 开发覆盖配置:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
## 账户与邮件流程
1. 登录先校验用户名或邮箱、密码和图形验证码。
2. 未受信设备会收到 6 位邮箱验证码。验证码默认 10 分钟有效,重发间隔默认 60 秒。
3. 勾选“信任此设备”后,30 天内无需邮箱二次认证。勾选“保持登录状态”后,登录状态最长保持 7 天。
4. 找回密码会发送一次性重置链接。链接使用后失效,重置成功会撤销其他会话和受信设备。
5. 管理员可发送注册链接。受邀用户可在公开注册关闭时完成注册,注册链接同样为一次性链接。
邮件服务不可用时,邮箱验证码、密码重置和邀请注册无法完成。请确认 Resend 的 API 密钥、发件人域名和 DNS 验证状态正确。
## 反向代理建议
公开部署建议由 Nginx 提供 HTTPS,并将请求头转发给应用:
```nginx
location / {
proxy_pass http://127.0.0.1:5005;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
应用会基于当前请求生成密码重置和注册链接。请确保用户访问的公网域名能够正确转发到该服务。
### NPMplus 上传文件返回 500
如果小请求可以正常转发,但上传稍大的文件时 Nginx 返回 HTML 格式的 500,先检查 NPMplus 的错误日志:
```bash
sudo docker exec npmplus sh -lc \
"grep -E 'client_body_temp.*Permission denied' /data/nginx/logs/error.log | tail"
```
Nginx 会在请求体超过内存缓冲区后将内容写入 `client_body_temp`。当 NPMplus 以非 root 的 `PUID`、`PGID` 运行,而 `/usr/local/nginx/*_temp` 目录被 root 拥有且权限为 `0700` 时,写入会因权限不足失败并返回 500。可按实际的 `PUID`、`PGID` 修复全部临时目录;下面以 `1000:1000` 为例:
```bash
sudo docker exec -u 0 npmplus sh -lc '
for name in client_body_temp proxy_temp fastcgi_temp scgi_temp uwsgi_temp; do
chown -R 1000:1000 "/usr/local/nginx/$name"
chmod 700 "/usr/local/nginx/$name"
done
'
sudo docker exec -u 1000:1000 npmplus nginx -t
```
不要在 NPMplus 的 Compose 服务中直接设置 `user: 1000:1000`,其启动脚本需要先以 root 初始化,再根据 `PUID`、`PGID` 降权运行。检查 Nginx 配置时也应指定相同的运行账号:
```bash
sudo docker exec -u 1000:1000 npmplus nginx -t
sudo docker exec -u 1000:1000 npmplus nginx -T
```
以 root 身份执行 `nginx -t` 或 `nginx -T` 可能重新改变临时目录属主,使上传故障复发。此问题与 HTTPS、CrowdSec 拦截和 `client_max_body_size` 无关;如果响应为 413 或 403,应分别检查请求体大小限制和访问控制日志。
## 测试
```bash
python -m pytest -q
```
测试不使用真实 Resend 密钥,也不会发送邮件。
+2 -1
View File
@@ -82,7 +82,8 @@ class Config:
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower() ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "").strip().lower()
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai" APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai"
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 30) # 密码重置和管理员邀请链接均为一次性链接,默认 10 分钟后失效。
PASSWORD_RESET_TOKEN_MINUTES = env_int("PASSWORD_RESET_TOKEN_MINUTES", 10)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "").strip() RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "").strip()
RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip() RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "").strip()
EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10) EMAIL_CODE_MINUTES = env_int("EMAIL_CODE_MINUTES", 10)
+15 -5
View File
@@ -39,14 +39,24 @@ def verification_code_email(*, code: str, minutes: int, purpose: str) -> str:
return render_transactional_email(title="邮箱验证码", content=content) return render_transactional_email(title="邮箱验证码", content=content)
def password_reset_notice_email(*, username: str, reset_url: str) -> str: def password_reset_notice_email(*, username: str, reset_url: str, minutes: int) -> str:
"""Render an administrator-initiated password reset notification.""" """Render a one-time password reset link email."""
safe_url = escape(reset_url, quote=True) safe_url = escape(reset_url, quote=True)
content = f""" content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">{escape(username)},管理员已要求你重置密码。</p> <p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">{escape(username)},请通过下方按钮设置新密码。</p>
<p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">请通过下方按钮进入找回密码流程,系统会向本邮箱发送一次性验证码。</p> <p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">该链接仅可使用一次,并将在 {minutes} 分钟后失效。</p>
<p style="margin:0;"><a href="{safe_url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#005eb8;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">重置密码</a></p>""" <p style="margin:0;"><a href="{safe_url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#005eb8;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">重置密码</a></p>"""
return render_transactional_email(title="请重置密码", content=content) return render_transactional_email(title="重置账户密码", content=content)
def registration_invitation_email(*, invitation_url: str, minutes: int) -> str:
"""Render a one-time invitation link email for an administrator-created account."""
safe_url = escape(invitation_url, quote=True)
content = f"""
<p style="margin:0;color:#475569;font-size:15px;line-height:1.8;">管理员邀请你加入供水管道健康评估系统。</p>
<p style="margin:16px 0 24px;color:#475569;font-size:15px;line-height:1.8;">请通过下方按钮设置显示名和密码。该链接仅可使用一次,并将在 {minutes} 分钟后失效。</p>
<p style="margin:0;"><a href="{safe_url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#005eb8;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">接受邀请并注册</a></p>"""
return render_transactional_email(title="管理员邀请你注册", content=content)
def send_transactional_email(*, to: str, subject: str, html: str) -> dict[str, Any]: def send_transactional_email(*, to: str, subject: str, html: str) -> dict[str, Any]:
+19 -14
View File
@@ -6,17 +6,22 @@ from .extensions import db
def upgrade_schema() -> None: def upgrade_schema() -> None:
"""Create new tables and add authentication columns without losing existing data.""" """Create new tables and add authentication columns without losing existing data."""
db.create_all() with db.engine.begin() as connection:
inspector = inspect(db.engine) # Gunicorn workers can import the application simultaneously. Acquiring
columns = {item["name"] for item in inspector.get_columns("users")} # SQLite's write lock before inspecting or creating tables serializes the
additions = { # first-start migration and prevents duplicate CREATE TABLE attempts.
"email": "VARCHAR(254)", if db.engine.dialect.name == "sqlite":
"email_verified_at": "DATETIME", connection.exec_driver_sql("BEGIN IMMEDIATE")
"is_active_account": "BOOLEAN NOT NULL DEFAULT 0", db.metadata.create_all(bind=connection)
"auth_version": "INTEGER NOT NULL DEFAULT 1", inspector = inspect(connection)
} columns = {item["name"] for item in inspector.get_columns("users")}
for name, sql_type in additions.items(): additions = {
if name not in columns: "email": "VARCHAR(254)",
db.session.execute(text(f"ALTER TABLE users ADD COLUMN {name} {sql_type}")) "email_verified_at": "DATETIME",
db.session.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_unique ON users (email) WHERE email IS NOT NULL")) "is_active_account": "BOOLEAN NOT NULL DEFAULT 0",
db.session.commit() "auth_version": "INTEGER NOT NULL DEFAULT 1",
}
for name, sql_type in additions.items():
if name not in columns:
connection.execute(text(f"ALTER TABLE users ADD COLUMN {name} {sql_type}"))
connection.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_unique ON users (email) WHERE email IS NOT NULL"))
+14
View File
@@ -53,6 +53,20 @@ class PasswordResetToken(db.Model):
created_by = db.relationship("User", foreign_keys=[created_by_id]) created_by = db.relationship("User", foreign_keys=[created_by_id])
class RegistrationInvitation(db.Model):
__tablename__ = "registration_invitations"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(254), nullable=False, index=True)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
used_at = db.Column(db.DateTime)
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(db.DateTime, default=utc_now, nullable=False)
created_by = db.relationship("User", foreign_keys=[created_by_id])
class EmailVerificationCode(db.Model): class EmailVerificationCode(db.Model):
__tablename__ = "email_verification_codes" __tablename__ = "email_verification_codes"
+434 -73
View File
@@ -8,18 +8,28 @@ from datetime import datetime, timedelta
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_file, session, url_for
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload from sqlalchemy import or_
from sqlalchemy.orm import joinedload, selectinload
from .config import BASE_DIR from .config import BASE_DIR
from .email import ( from .email import (
EmailConfigurationError, EmailConfigurationError,
EmailDeliveryError, EmailDeliveryError,
password_reset_notice_email, password_reset_notice_email,
registration_invitation_email,
send_transactional_email, send_transactional_email,
verification_code_email, verification_code_email,
) )
from .extensions import db from .extensions import db
from .models import AppSetting, EmailVerificationCode, TrustedDevice, UploadRecord, User from .models import (
AppSetting,
EmailVerificationCode,
PasswordResetToken,
RegistrationInvitation,
TrustedDevice,
UploadRecord,
User,
)
from .prediction import PredictionError, run_prediction from .prediction import PredictionError, run_prediction
from .security import new_captcha from .security import new_captcha
from .time_utils import format_datetime_for_timezone, utc_now from .time_utils import format_datetime_for_timezone, utc_now
@@ -29,11 +39,11 @@ REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩
TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx" TEMPLATE_EXCEL_NAME = "管道预测数据模板.xlsx"
REGISTRATION_SETTING_KEY = "allow_registration" REGISTRATION_SETTING_KEY = "allow_registration"
RECORDS_PER_PAGE = 10 RECORDS_PER_PAGE = 10
USERS_PER_PAGE = 20
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
EMAIL_CODE_PURPOSES = { EMAIL_CODE_PURPOSES = {
"register", "register",
"login", "login",
"reset",
"change_password", "change_password",
"change_email_old", "change_email_old",
"change_email_new", "change_email_new",
@@ -41,7 +51,6 @@ EMAIL_CODE_PURPOSES = {
EMAIL_CODE_LABELS = { EMAIL_CODE_LABELS = {
"register": "完成注册", "register": "完成注册",
"login": "登录确认", "login": "登录确认",
"reset": "重置密码",
"change_password": "修改密码", "change_password": "修改密码",
"change_email_old": "确认原邮箱", "change_email_old": "确认原邮箱",
"change_email_new": "确认新邮箱", "change_email_new": "确认新邮箱",
@@ -57,6 +66,98 @@ def valid_email(value: str) -> bool:
return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value)) return len(value) <= 254 and bool(EMAIL_RE.fullmatch(value))
def masked_email(value: str) -> str:
"""Return an email suitable for confirmation screens without exposing it."""
local_part, separator, domain = normal_email(value).partition("@")
if not separator or not local_part or not domain:
return "已绑定邮箱"
if len(local_part) == 1:
visible_local = "*"
elif len(local_part) == 2:
visible_local = f"{local_part[0]}*"
elif len(local_part) <= 4:
visible_local = f"{local_part[0]}**{local_part[-1]}"
else:
visible_local = f"{local_part[:2]}****{local_part[-2:]}"
return f"{visible_local}@{domain}"
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def issue_password_reset_token(user: User, created_by: User) -> str:
"""Invalidate outstanding links for a user and create a fresh one-time reset link."""
now = utc_now()
PasswordResetToken.query.filter_by(user_id=user.id, used_at=None).update(
{"expires_at": now}, synchronize_session=False
)
token = secrets.token_urlsafe(32)
db.session.add(
PasswordResetToken(
user_id=user.id,
created_by_id=created_by.id,
token_hash=token_hash(token),
expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]),
)
)
db.session.commit()
return token
def active_password_reset_token(token: str) -> PasswordResetToken | None:
return PasswordResetToken.query.filter(
PasswordResetToken.token_hash == token_hash(token),
PasswordResetToken.used_at.is_(None),
PasswordResetToken.expires_at > utc_now(),
).first()
def issue_registration_invitation(email: str, created_by: User) -> str:
"""Create a fresh invitation and invalidate any previous unused invitation."""
now = utc_now()
RegistrationInvitation.query.filter_by(email=email, used_at=None).update(
{"expires_at": now}, synchronize_session=False
)
token = secrets.token_urlsafe(32)
db.session.add(
RegistrationInvitation(
email=email,
created_by_id=created_by.id,
token_hash=token_hash(token),
expires_at=now + timedelta(minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"]),
)
)
db.session.commit()
return token
def active_registration_invitation(token: str) -> RegistrationInvitation | None:
return RegistrationInvitation.query.filter(
RegistrationInvitation.token_hash == token_hash(token),
RegistrationInvitation.used_at.is_(None),
RegistrationInvitation.expires_at > utc_now(),
).first()
def claim_token(model, token_id: int) -> bool:
"""Atomically consume a still-valid, one-time link token."""
return (
model.query.filter(
model.id == token_id,
model.used_at.is_(None),
model.expires_at > utc_now(),
).update({"used_at": utc_now()}, synchronize_session=False)
== 1
)
def revoke_user_authentication(user: User) -> None:
"""Invalidate browser sessions and trusted-device cookies for a user."""
user.revoke_authentication()
TrustedDevice.query.filter_by(user_id=user.id).delete()
def valid_password(password: str) -> bool: def valid_password(password: str) -> bool:
return ( return (
12 <= len(password) <= 128 12 <= len(password) <= 128
@@ -71,6 +172,14 @@ def valid_password(password: str) -> bool:
PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。" PASSWORD_RULE_MESSAGE = "密码须为12至128位,包含大写字母、小写字母、数字和特殊字符,且不能含空格。"
def valid_new_password(user: User, password: str, confirmation: str) -> bool:
return (
valid_password(password)
and password == confirmation
and not user.check_password(password)
)
def grant_fresh_authorization(user: User, purpose: str) -> None: def grant_fresh_authorization(user: User, purpose: str) -> None:
"""Grant a short, session-bound authorization after successful email MFA.""" """Grant a short, session-bound authorization after successful email MFA."""
session[f"fresh_auth_{purpose}"] = { session[f"fresh_auth_{purpose}"] = {
@@ -101,8 +210,18 @@ def consume_fresh_authorization(purpose: str) -> None:
session.pop(f"fresh_auth_{purpose}", None) session.pop(f"fresh_auth_{purpose}", None)
def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""): def render_auth_template(
return render_template("login.html", mode=mode, captcha=captcha), status_code mode: str,
status_code: int = 200,
captcha: str = "",
auth_error_field: str | None = None,
):
return render_template(
"login.html",
mode=mode,
captcha=captcha,
auth_error_field=auth_error_field,
), status_code
def refresh_captcha() -> str: def refresh_captcha() -> str:
@@ -110,9 +229,14 @@ def refresh_captcha() -> str:
return session["captcha"] return session["captcha"]
def render_auth_error(mode: str, message: str, status_code: int = 400): def render_auth_error(
mode: str,
message: str,
status_code: int = 400,
auth_error_field: str | None = None,
):
flash(message, "error") flash(message, "error")
return render_auth_template(mode, status_code, refresh_captcha()) return render_auth_template(mode, status_code, refresh_captcha(), auth_error_field)
def captcha_is_valid() -> bool: def captcha_is_valid() -> bool:
@@ -129,9 +253,9 @@ def registration_allowed() -> bool:
return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"]) return AppSetting.get_bool(REGISTRATION_SETTING_KEY, current_app.config["ALLOW_REGISTRATION"])
def requested_page() -> int: def requested_page(parameter: str = "page") -> int:
try: try:
return max(int(request.args.get("page", 1)), 1) return max(int(request.args.get(parameter, 1)), 1)
except (TypeError, ValueError): except (TypeError, ValueError):
return 1 return 1
@@ -148,16 +272,59 @@ def paginated_uploads(query, endpoint: str):
return pagination, None return pagination, None
def paginated_users():
search = request.args.get("q", "").strip()
page = requested_page("user_page")
query = User.query.filter(User.is_admin.is_(False)).options(
selectinload(User.trusted_devices)
)
if search:
pattern = f"%{search}%"
query = query.filter(
or_(User.username.ilike(pattern), User.email.ilike(pattern))
)
pagination = query.order_by(User.created_at.desc(), User.id.desc()).paginate(
page=page,
per_page=USERS_PER_PAGE,
error_out=False,
)
if pagination.pages and page > pagination.pages:
return pagination, search, redirect(
url_for(
"main.admin_dashboard",
page=requested_page(),
user_page=pagination.pages,
q=search,
)
)
return pagination, search, None
def admin_dashboard_redirect():
return redirect(
url_for(
"main.admin_dashboard",
user_page=request.form.get("user_page", 1),
q=request.form.get("q", ""),
)
)
def code_hash(email: str, purpose: str, code: str) -> str: def code_hash(email: str, purpose: str, code: str) -> str:
return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest() return hashlib.sha256(f"{email}:{purpose}:{code}".encode()).hexdigest()
def issue_email_code(email: str, purpose: str) -> bool: def issue_email_code(email: str, purpose: str) -> str | None:
"""Issue an email code, returning a failure reason when it cannot be sent."""
now = utc_now() now = utc_now()
resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"]) resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"])
recent = ( recent = (
EmailVerificationCode.query.filter_by(email=email, purpose=purpose) EmailVerificationCode.query.filter_by(email=email, purpose=purpose)
.filter(EmailVerificationCode.created_at >= resend_at) .filter(
EmailVerificationCode.created_at >= resend_at,
EmailVerificationCode.expires_at > now,
EmailVerificationCode.used_at.is_(None),
)
.first() .first()
) )
ip = request.remote_addr or "" ip = request.remote_addr or ""
@@ -165,8 +332,10 @@ def issue_email_code(email: str, purpose: str) -> bool:
EmailVerificationCode.created_at >= now - timedelta(hours=1), EmailVerificationCode.created_at >= now - timedelta(hours=1),
EmailVerificationCode.requested_ip == ip, EmailVerificationCode.requested_ip == ip,
).count() ).count()
if recent or hourly >= 20: if recent:
return False return "resend_wait"
if hourly >= 20:
return "rate_limited"
EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now}) EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now})
code = f"{secrets.randbelow(1_000_000):06d}" code = f"{secrets.randbelow(1_000_000):06d}"
record = EmailVerificationCode( record = EmailVerificationCode(
@@ -181,7 +350,7 @@ def issue_email_code(email: str, purpose: str) -> bool:
try: try:
send_transactional_email( send_transactional_email(
to=email, to=email,
subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", subject=f"【管道健康】{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码",
html=verification_code_email( html=verification_code_email(
code=code, code=code,
minutes=current_app.config["EMAIL_CODE_MINUTES"], minutes=current_app.config["EMAIL_CODE_MINUTES"],
@@ -191,8 +360,16 @@ def issue_email_code(email: str, purpose: str) -> bool:
except (EmailConfigurationError, EmailDeliveryError): except (EmailConfigurationError, EmailDeliveryError):
db.session.delete(record) db.session.delete(record)
db.session.commit() db.session.commit()
return False return "delivery_failed"
return True return None
def email_code_issue_message(reason: str) -> str:
if reason == "resend_wait":
return f"验证码已发送,请 {current_app.config['EMAIL_CODE_RESEND_SECONDS']} 秒后再试。"
if reason == "rate_limited":
return "请求过于频繁,请稍后再试。"
return "验证码发送失败,请稍后重试。"
def consume_email_code(email: str, purpose: str, code: str) -> bool: def consume_email_code(email: str, purpose: str, code: str) -> bool:
@@ -262,7 +439,7 @@ def verification_page(title: str, purpose: str):
"verification.html", "verification.html",
title=title, title=title,
purpose=purpose, purpose=purpose,
email=session.get("pending_email", ""), email=masked_email(session.get("pending_email", "")),
resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"], resend_seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"],
) )
@@ -278,34 +455,56 @@ def login():
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
if request.method == "GET": if request.method == "GET":
return render_auth_template("login", captcha=refresh_captcha()) return render_auth_template("login", captcha=refresh_captcha())
email, password = normal_email(request.form.get("email", "")), request.form.get("password", "") identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "")
if not captcha_is_valid(): return render_auth_error("login", "验证码错误") if not captcha_is_valid():
user = User.query.filter_by(email=email).first() return render_auth_error("login", "图形验证码错误", auth_error_field="captcha")
user = User.query.filter(
or_(User.username == identifier, User.email == normal_email(identifier))
).first()
if not user or not user.is_active_account or not user.check_password(password): if not user or not user.is_active_account or not user.check_password(password):
return render_auth_error("login", "邮箱或密码错误") return render_auth_error("login", "用户名、邮箱或密码错误")
remember = bool(request.form.get("remember")) remember = bool(request.form.get("remember"))
if trusted_device_for(user): if trusted_device_for(user):
return login_response(user, remember) return login_response(user, remember)
session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login")
if not issue_email_code(email, "login"): if reason := issue_email_code(user.email, "login"):
return render_auth_error("login", "验证码发送失败,请稍后重试", 503) if reason == "resend_wait":
flash("邮箱验证码已发送,请输入验证码完成二次认证。", "info")
return redirect(url_for("main.verify_email", purpose="login"))
return render_auth_error(
"login", email_code_issue_message(reason),
503 if reason == "delivery_failed" else 429,
)
return redirect(url_for("main.verify_email", purpose="login")) return redirect(url_for("main.verify_email", purpose="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()
if not captcha_is_valid(): return render_auth_error("register", "验证码错误") email = normal_email(request.form.get("email", ""))
password = request.form.get("password", "")
confirmation = request.form.get("password_confirm", "")
if not captcha_is_valid():
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)
if not username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符") if not username or len(username) > 100:
if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址") return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username")
if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE) if not valid_email(email):
return render_auth_error("register", "请输入有效的邮箱地址", auth_error_field="email")
if not valid_password(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()
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register") session.update(pending_email=email, pending_user_id=user.id, pending_purpose="register")
if not issue_email_code(email, "register"): return render_auth_error("register", "验证码发送失败,请稍后重试", 503) if reason := issue_email_code(email, "register"):
return render_auth_error(
"register", email_code_issue_message(reason),
503 if reason == "delivery_failed" else 429,
)
return redirect(url_for("main.verify_email", purpose="register")) return redirect(url_for("main.verify_email", purpose="register"))
@@ -313,29 +512,35 @@ def register():
def verify_email(purpose: str): def verify_email(purpose: str):
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
abort(400) abort(400)
if request.method == "GET": return verification_page("邮箱验证", purpose) verification_title = "邮箱二次认证" if purpose == "login" else "邮箱验证"
if request.method == "GET": return verification_page(verification_title, purpose)
email = session.get("pending_email", "") email = session.get("pending_email", "")
if not consume_email_code(email, purpose, request.form.get("code", "")): if not consume_email_code(email, purpose, request.form.get("code", "")):
flash("验证码无效、过期或尝试次数已用尽。", "error"); return verification_page("邮箱验证", purpose), 400 flash("邮箱验证码无效、过期或尝试次数已用尽。", "error"); return verification_page(verification_title, purpose), 400
user = db.session.get(User, session.get("pending_user_id")) user = db.session.get(User, session.get("pending_user_id"))
if not user or (purpose != "change_email_new" and user.email != email): abort(400) if not user or (purpose != "change_email_new" and user.email != email): abort(400)
if purpose == "register": if purpose == "register":
user.email_verified_at = utc_now(); user.is_active_account = True; db.session.commit(); session.clear(); flash("邮箱验证成功,请登录。", "info"); return redirect(url_for("main.login")) user.email_verified_at = utc_now()
user.is_active_account = True
db.session.commit()
session.clear()
flash("邮箱验证成功,欢迎使用系统。", "info")
return login_response(user, remember=False)
if purpose == "login": if purpose == "login":
remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True) remember = bool(session.get("pending_remember"))
if purpose == "reset": trust_device = bool(request.form.get("trust_device"))
grant_fresh_authorization(user, "password_reset") session.clear()
return redirect(url_for("main.set_password")) return login_response(user, remember, trust_device=trust_device)
if purpose == "change_email_old": if purpose == "change_email_old":
new_email = session.get("new_email", "") new_email = session.get("new_email", "")
session.update(pending_email=new_email, pending_purpose="change_email_new") session.update(pending_email=new_email, pending_purpose="change_email_new")
if not issue_email_code(new_email, "change_email_new"): if reason := issue_email_code(new_email, "change_email_new"):
flash("新邮箱验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
return redirect(url_for("main.verify_email", purpose="change_email_new")) return redirect(url_for("main.verify_email", purpose="change_email_new"))
if purpose == "change_email_new": if purpose == "change_email_new":
if User.query.filter(User.email == email, User.id != user.id).first(): if User.query.filter(User.email == email, User.id != user.id).first():
flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security")) flash("该邮箱已被使用。", "error"); return redirect(url_for("main.account_security"))
user.email = email; user.email_verified_at = utc_now(); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login")) user.email = email; user.email_verified_at = utc_now(); revoke_user_authentication(user); db.session.commit(); logout_user(); session.clear(); flash("邮箱已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
grant_fresh_authorization(user, "password_change") grant_fresh_authorization(user, "password_change")
return redirect(url_for("main.account_security")) return redirect(url_for("main.account_security"))
@@ -344,7 +549,8 @@ def verify_email(purpose: str):
def resend_code(purpose: str): def resend_code(purpose: str):
if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose:
abort(400) abort(400)
if not issue_email_code(session.get("pending_email", ""), purpose): flash("发送过于频繁或服务暂不可用,请稍后再试。", "error") if reason := issue_email_code(session.get("pending_email", ""), purpose):
flash(email_code_issue_message(reason), "error")
else: flash("验证码已发送,请查收邮箱。", "info") else: flash("验证码已发送,请查收邮箱。", "info")
return redirect(url_for("main.verify_email", purpose=purpose)) return redirect(url_for("main.verify_email", purpose=purpose))
@@ -354,23 +560,89 @@ def forgot_password():
if request.method == "GET": return render_template("forgot_password.html") if request.method == "GET": return render_template("forgot_password.html")
email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first() email = normal_email(request.form.get("email", "")); user = User.query.filter_by(email=email, is_active_account=True).first()
if user: if user:
session.update(pending_email=email, pending_user_id=user.id, pending_purpose="reset") token = issue_password_reset_token(user, user)
issue_email_code(email, "reset") try:
flash("若该邮箱已注册,验证码将发送至邮箱。", "info") send_transactional_email(
to=user.email,
subject="【管道健康】重置密码",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.reset_password_with_token", token=token, _external=True),
minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
),
)
except (EmailConfigurationError, EmailDeliveryError):
current_app.logger.exception("密码重置邮件发送失败")
flash("若该邮箱已注册,重置链接将发送至邮箱。", "info")
return redirect(url_for("main.forgot_password"))
@bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password_with_token(token: str):
reset_token = active_password_reset_token(token)
if reset_token is None or not reset_token.user.is_active_account:
flash("该重置链接无效、已过期或已使用。请重新申请。", "error")
return redirect(url_for("main.forgot_password"))
if request.method == "GET":
return render_template("reset_password.html", token=token)
password = request.form.get("password", "")
confirm = request.form.get("password_confirm", "")
if not valid_new_password(reset_token.user, password, confirm):
flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error")
return render_template("reset_password.html", token=token), 400
if not claim_token(PasswordResetToken, reset_token.id):
db.session.rollback()
flash("该重置链接已失效,请重新申请。", "error")
return redirect(url_for("main.forgot_password"))
reset_token.user.set_password(password)
revoke_user_authentication(reset_token.user)
db.session.commit()
flash("密码已重置,请使用新密码登录。", "info")
return redirect(url_for("main.login")) return redirect(url_for("main.login"))
@bp.route("/set-password", methods=["GET", "POST"]) @bp.route("/invite/<token>", methods=["GET", "POST"])
def set_password(): def accept_registration_invitation(token: str):
authorization = session.get("fresh_auth_password_reset") invitation = active_registration_invitation(token)
user = db.session.get(User, authorization.get("user_id")) if isinstance(authorization, dict) else None if invitation is None:
if not user or not has_fresh_authorization(user, "password_reset"): flash("该注册链接无效、已过期或已使用。请联系管理员重新邀请。", "error")
consume_fresh_authorization("password_reset") return redirect(url_for("main.login"))
return redirect(url_for("main.forgot_password")) if User.query.filter_by(email=invitation.email).first():
if request.method == "GET": return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")) flash("该邮箱已注册,请直接登录或联系管理员。", "error")
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") return redirect(url_for("main.login"))
if not valid_password(password) or password != confirm: flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error"); return render_template("set_password.html", title="设置新密码", action=url_for("main.set_password")), 400 if request.method == "GET":
user.set_password(password); user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=user.id).delete(); db.session.commit(); consume_fresh_authorization("password_reset"); session.clear(); flash("密码已重置,请重新登录。", "info"); return redirect(url_for("main.login")) return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email))
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
confirm = request.form.get("password_confirm", "")
if not username or len(username) > 100:
flash("显示名不能为空且不能超过100个字符。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if User.query.filter(User.username == username).first():
flash("该显示名已被使用。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if not valid_password(password) or password != confirm:
flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致。", "error")
return render_template("accept_invitation.html", token=token, email=masked_email(invitation.email)), 400
if not claim_token(RegistrationInvitation, invitation.id):
db.session.rollback()
flash("该注册链接已失效,请联系管理员重新邀请。", "error")
return redirect(url_for("main.login"))
user = User(
username=username,
email=invitation.email,
email_verified_at=utc_now(),
is_active_account=True,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
session.clear()
flash("注册成功,欢迎使用系统。", "info")
return login_response(user, remember=False)
@bp.route("/account/security", methods=["GET", "POST"]) @bp.route("/account/security", methods=["GET", "POST"])
@@ -389,7 +661,7 @@ def account_security():
if not current_user.check_password(request.form.get("current_password", "")): if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确,未撤销受信设备。", "error") flash("当前密码不正确,未撤销受信设备。", "error")
return redirect(url_for("main.account_security")) return redirect(url_for("main.account_security"))
current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login")) revoke_user_authentication(current_user); db.session.commit(); logout_user(); flash("所有受信设备已撤销,请重新登录。", "info"); return redirect(url_for("main.login"))
if not current_user.check_password(request.form.get("current_password", "")): if not current_user.check_password(request.form.get("current_password", "")):
flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security")) flash("当前密码不正确。", "error"); return redirect(url_for("main.account_security"))
if action == "change_email": if action == "change_email":
@@ -397,10 +669,12 @@ def account_security():
if not valid_email(new_email) or User.query.filter_by(email=new_email).first(): if not valid_email(new_email) or User.query.filter_by(email=new_email).first():
flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security")) flash("请输入未被使用的有效邮箱地址。", "error"); return redirect(url_for("main.account_security"))
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email) session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_email_old", new_email=new_email)
if not issue_email_code(current_user.email, "change_email_old"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) if reason := issue_email_code(current_user.email, "change_email_old"):
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
return redirect(url_for("main.verify_email", purpose="change_email_old")) return redirect(url_for("main.verify_email", purpose="change_email_old"))
session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password") session.update(pending_email=current_user.email, pending_user_id=current_user.id, pending_purpose="change_password")
if not issue_email_code(current_user.email, "change_password"): flash("验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) if reason := issue_email_code(current_user.email, "change_password"):
flash(email_code_issue_message(reason), "error"); return redirect(url_for("main.account_security"))
return redirect(url_for("main.verify_email", purpose="change_password")) return redirect(url_for("main.verify_email", purpose="change_password"))
@@ -412,7 +686,7 @@ def change_password():
abort(403) abort(403)
password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "") password, confirm = request.form.get("password", ""), request.form.get("password_confirm", "")
if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security")) if not valid_password(password) or password != confirm or current_user.check_password(password): flash(f"{PASSWORD_RULE_MESSAGE} 两次输入必须一致,且不能与当前密码相同。", "error"); return redirect(url_for("main.account_security"))
current_user.set_password(password); current_user.revoke_authentication(); TrustedDevice.query.filter_by(user_id=current_user.id).delete(); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login")) current_user.set_password(password); revoke_user_authentication(current_user); db.session.commit(); consume_fresh_authorization("password_change"); logout_user(); flash("密码已更新,请重新登录。", "info"); return redirect(url_for("main.login"))
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
@@ -432,31 +706,118 @@ def history_page():
@bp.route("/admin") @bp.route("/admin")
@login_required @login_required
def admin_dashboard(): def admin_dashboard():
require_admin(); pagination, page_redirect = paginated_uploads(UploadRecord.query.options(joinedload(UploadRecord.user)), "main.admin_dashboard") require_admin()
return page_redirect or render_template("admin.html", pagination=pagination, records=pagination.items, password_reset_users=User.query.filter(User.is_admin.is_(False)).order_by(User.username).all(), registration_allowed=registration_allowed()) pagination, page_redirect = paginated_uploads(
UploadRecord.query.options(joinedload(UploadRecord.user)),
"main.admin_dashboard",
)
user_pagination, user_search, user_page_redirect = paginated_users()
active_device_counts = {
user.id: sum(
device.expires_at > utc_now()
and device.auth_version == user.auth_version
for device in user.trusted_devices
)
for user in user_pagination.items
}
if page_redirect or user_page_redirect:
return page_redirect or user_page_redirect
return render_template(
"admin.html",
pagination=pagination,
records=pagination.items,
user_pagination=user_pagination,
user_search=user_search,
active_device_counts=active_device_counts,
registration_allowed=registration_allowed(),
)
@bp.route("/admin/registration", methods=["POST"]) @bp.route("/admin/registration", methods=["POST"])
@login_required @login_required
def update_registration_setting(): def update_registration_setting():
require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard")) require_admin(); enabled = request.form.get("allow_registration") == "on"; AppSetting.set_bool(REGISTRATION_SETTING_KEY, enabled); db.session.commit(); flash("已开放用户自助注册" if enabled else "已关闭用户自助注册", "info"); return redirect(url_for("main.admin_dashboard"))
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
@bp.route("/admin/invitations", methods=["POST"])
@login_required @login_required
def admin_password_reset(user_id: int): def admin_send_registration_invitation():
require_admin(); user = db.session.get(User, user_id) require_admin()
if not user or user.is_admin: return jsonify({"error": "用户不存在或不支持此操作"}), 404 email = normal_email(request.form.get("email", ""))
if not valid_email(email):
flash("请输入有效的受邀邮箱。", "error")
return admin_dashboard_redirect()
if User.query.filter_by(email=email).first():
flash("该邮箱已注册,不能重复发送邀请。", "error")
return admin_dashboard_redirect()
token = issue_registration_invitation(email, current_user)
try: try:
send_transactional_email( send_transactional_email(
to=user.email, to=email,
subject="供水管道健康评估系统:请重置密码", subject="【管道健康】注册邀请",
html=password_reset_notice_email( html=registration_invitation_email(
username=user.username, invitation_url=url_for("main.accept_registration_invitation", token=token, _external=True),
reset_url=url_for("main.forgot_password", _external=True), minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
), ),
) )
except (EmailConfigurationError, EmailDeliveryError): except (EmailConfigurationError, EmailDeliveryError):
return jsonify({"error": "邮件发送失败"}), 503 flash("邀请链接已生成,但邮件发送失败,请检查邮件服务配置后重新发送。", "error")
return jsonify({"message": "密码重置通知已发送至用户邮箱"}) else:
flash("注册链接已发送至受邀邮箱。", "info")
return admin_dashboard_redirect()
@bp.route("/admin/users/<int:user_id>/password-reset", methods=["POST"])
@login_required
def admin_password_reset(user_id: int):
require_admin()
user = db.session.get(User, user_id)
if not user or user.is_admin:
abort(404)
token = issue_password_reset_token(user, current_user)
try:
send_transactional_email(
to=user.email,
subject="【管道健康】重置密码",
html=password_reset_notice_email(
username=user.username,
reset_url=url_for("main.reset_password_with_token", token=token, _external=True),
minutes=current_app.config["PASSWORD_RESET_TOKEN_MINUTES"],
),
)
except (EmailConfigurationError, EmailDeliveryError):
flash("邮件发送失败,请稍后重试。", "error")
else:
flash("密码重置通知已发送至用户邮箱。", "info")
return admin_dashboard_redirect()
@bp.route("/admin/users/<int:user_id>/trusted-devices/revoke", methods=["POST"])
@login_required
def admin_revoke_trusted_devices(user_id: int):
require_admin()
user = db.session.get(User, user_id)
if user is None or user.is_admin:
abort(404)
revoke_user_authentication(user)
db.session.commit()
flash(f"已撤销 {user.username} 的所有受信设备。", "info")
return admin_dashboard_redirect()
@bp.route("/admin/users/<int:user_id>/account-status", methods=["POST"])
@login_required
def admin_update_account_status(user_id: int):
require_admin()
user = db.session.get(User, user_id)
if user is None or user.is_admin:
abort(404)
user.is_active_account = request.form.get("is_active") == "true"
revoke_user_authentication(user)
db.session.commit()
message = "已启用账号" if user.is_active_account else "已停用账号并撤销所有会话"
flash(f"{user.username}:{message}。", "info")
return admin_dashboard_redirect()
@bp.route("/download/<int:record_id>/<file_type>") @bp.route("/download/<int:record_id>/<file_type>")
@login_required @login_required
+5 -8
View File
@@ -1,8 +1,6 @@
services: services:
pipeline-lifetime: pipeline-lifetime:
build: image: pipeline-lifetime:20260805.3
context: .
image: pipeline-lifetime:latest
container_name: pipeline-lifetime container_name: pipeline-lifetime
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -10,19 +8,19 @@ services:
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival_0331.db} DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/pipe_survival.db}
APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-16777216}
FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core} FUSION_MODEL_CORE_DIR: ${FUSION_MODEL_CORE_DIR:-/app/model_core}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-30}
RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-} RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10} EMAIL_CODE_MINUTES: ${EMAIL_CODE_MINUTES:-10}
EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60} EMAIL_CODE_RESEND_SECONDS: ${EMAIL_CODE_RESEND_SECONDS:-60}
EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5} EMAIL_CODE_MAX_ATTEMPTS: ${EMAIL_CODE_MAX_ATTEMPTS:-5}
PASSWORD_RESET_TOKEN_MINUTES: ${PASSWORD_RESET_TOKEN_MINUTES:-10}
FRESH_AUTH_MINUTES: ${FRESH_AUTH_MINUTES:-5} FRESH_AUTH_MINUTES: ${FRESH_AUTH_MINUTES:-5}
TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30} TRUSTED_DEVICE_DAYS: ${TRUSTED_DEVICE_DAYS:-30}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
@@ -32,4 +30,3 @@ services:
- ./data:/app/data - ./data:/app/data
- ./data/uploads:/app/uploads - ./data/uploads:/app/uploads
- ./data/images:/app/static/images - ./data/images:/app/static/images
- ./example.xlsx:/app/example.xlsx:ro
+1 -1
View File
File diff suppressed because one or more lines are too long
+121
View File
@@ -184,6 +184,107 @@
border-color: rgba(0, 94, 184, .22); border-color: rgba(0, 94, 184, .22);
border-top-color: #005EB8; border-top-color: #005EB8;
} }
/* Authentication-specific controls, compiled with the shared Tailwind bundle. */
.password-toggle {
position: absolute;
top: 50%;
right: .75rem;
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
transform: translateY(-50%);
border: 0;
border-radius: .5rem;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #005EB8;
}
.password-toggle:focus-visible {
outline: 2px solid #005EB8;
outline-offset: 2px;
}
.password-toggle:disabled {
cursor: not-allowed;
opacity: .45;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.captcha-token {
-webkit-user-select: none;
user-select: none;
}
.dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148, 163, 184, .30) 1.2px, transparent 0);
background-size: 42px 42px;
}
.panel-frame {
border: none;
box-shadow: none;
}
.gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
}
.auth-alert {
pointer-events: none;
top: max(1rem, env(safe-area-inset-top));
right: auto;
left: 50%;
width: calc(100vw - 2rem);
max-width: 26rem;
max-height: calc(100dvh - 2rem);
overflow-y: auto;
opacity: 0;
transform: translate(-50%, .5rem);
}
.auth-alert::before {
position: absolute;
top: -7px;
left: 24px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-top: 1px solid currentColor;
border-left: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
content: '';
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translate(-50%, 0);
}
} }
@keyframes spin { @keyframes spin {
@@ -191,3 +292,23 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@media (min-width: 1280px) {
.auth-alert {
width: 320px;
max-width: calc(100vw - 2rem);
transform: translateX(.75rem);
}
.auth-alert::before {
top: var(--auth-alert-arrow-top, 44px);
left: -7px;
border: 0;
border-bottom: 1px solid currentColor;
border-left: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#005EB8"/>
<path d="M32 10C25.5 19.1 18 27.3 18 37a14 14 0 1 0 28 0c0-9.7-7.5-17.9-14-27Z" fill="#fff"/>
<path d="M24.5 39.5c0 4.1 3.4 7.5 7.5 7.5" fill="none" stroke="#005EB8" stroke-linecap="round" stroke-width="3.5"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

-1
View File
@@ -1 +0,0 @@
<form method="post" class="space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">邮箱<input name="email" type="email" required class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="email"></label><label class="block text-sm font-semibold">密码<a class="float-right text-primary" href="{{ url_for('main.forgot_password') }}">找回密码</a><input name="password" type="password" required class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="current-password"></label><label class="block text-sm font-semibold">图形验证码<div class="mt-1 flex gap-2"><input name="captcha" required class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2"><span class="rounded-lg bg-blueSoft px-3 py-2 font-bold tracking-widest">{{ captcha }}</span><a class="rounded-lg border border-line px-2 py-2" href="{{ url_for('main.login') }}">↻</a></div></label><label class="flex gap-2 text-sm text-textSub"><input type="checkbox" name="remember">保持登录状态</label><button class="ui-btn ui-btn-lg ui-btn-primary w-full">登录</button></form>
+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-wrap items-center gap-2"> <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
View File
@@ -1 +0,0 @@
<form method="post" class="space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="block text-sm font-semibold">显示名<input name="username" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2"></label><label class="block text-sm font-semibold">邮箱<input name="email" type="email" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="email"></label><label class="block text-sm font-semibold">密码(12 至 128 位)<input name="password" type="password" minlength="12" required {{ 'disabled' if not allow_registration }} class="mt-1 w-full rounded-lg border border-line px-3 py-2" autocomplete="new-password"></label><label class="block text-sm font-semibold">图形验证码<div class="mt-1 flex gap-2"><input name="captcha" required {{ 'disabled' if not allow_registration }} class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2"><span class="rounded-lg bg-blueSoft px-3 py-2 font-bold tracking-widest">{{ captcha }}</span><a class="rounded-lg border border-line px-2 py-2" href="{{ url_for('main.register') }}">↻</a></div></label>{% if not allow_registration %}<p class="text-sm text-dangerText">当前未开放自助注册,请联系管理员。</p>{% endif %}<button {{ 'disabled' if not allow_registration }} class="ui-btn ui-btn-lg ui-btn-primary w-full">发送邮箱验证码</button></form>
+21
View File
@@ -0,0 +1,21 @@
{% extends "auth_base.html" %}
{% block title %}接受注册邀请{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<span class="material-symbols-outlined text-4xl text-primary" aria-hidden="true">mail</span>
<h1 class="mt-3 text-2xl font-extrabold">接受注册邀请</h1>
<p class="mt-2 text-sm leading-6 text-textSub">受邀邮箱:{{ email }}。请设置显示名和登录密码以激活账号。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<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 %}
<form method="post" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="block text-sm font-semibold">显示名<input name="username" required maxlength="100" autocomplete="username" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="请输入显示名"></label>
<label class="block text-sm font-semibold">设置密码<input name="password" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="12 位以上,包含大小写、数字和特殊字符"></label>
<label class="block text-sm font-semibold">确认密码<input name="password_confirm" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入密码"></label>
<button class="ui-btn ui-btn-primary w-full">完成注册</button>
</form>
</section>
{% endblock %}
+54 -14
View File
@@ -5,19 +5,19 @@
{% block content %} {% block content %}
<section class="mx-auto max-w-3xl rounded-xl border border-line bg-white shadow-panel"> <section class="mx-auto max-w-3xl rounded-xl border border-line bg-white shadow-panel">
<header class="border-b border-line px-6 py-6 sm:px-8"> <header class="border-b border-line px-5 py-6 sm:px-8">
<h1 class="text-2xl font-extrabold">账户安全</h1> <h1 class="text-2xl font-extrabold">账户安全</h1>
<p class="mt-2 text-sm text-textSub">登录邮箱:{{ current_user.email }}</p> <p class="mt-2 text-sm text-textSub">登录邮箱:{{ current_user.email }}</p>
</header> </header>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% for category, message in messages %}
<p class="mx-6 mt-5 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primary' }} sm:mx-8">{{ message }}</p> <p class="mx-5 mt-5 rounded-lg p-3 text-sm {{ 'bg-dangerSoft text-dangerText' if category == 'error' else 'bg-blueSoft text-primary' }} sm:mx-8">{{ message }}</p>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
<div class="divide-y divide-line"> <div class="divide-y divide-line">
<section class="px-6 py-7 sm:px-8"> <section class="px-5 py-7 sm:px-8">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">password</span> <span class="material-symbols-outlined mt-0.5 text-primary">password</span>
<div> <div>
@@ -30,18 +30,18 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"> <input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码">
<input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"> <input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码">
<button class="ui-btn ui-btn-primary sm:col-span-2 sm:w-fit">确认更新密码</button> <button class="ui-btn ui-btn-primary w-full sm:col-span-2 sm:w-fit">确认更新密码</button>
</form> </form>
{% else %} {% else %}
<form method="post" class="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center"> <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 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">发送密码验证码</button> <button class="ui-btn ui-btn-primary w-full sm:w-fit">发送密码验证码</button>
</form> </form>
{% endif %} {% endif %}
</section> </section>
<section class="px-6 py-7 sm:px-8"> <section class="px-5 py-7 sm:px-8">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">alternate_email</span> <span class="material-symbols-outlined mt-0.5 text-primary">alternate_email</span>
<div> <div>
@@ -49,16 +49,17 @@
<p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p> <p class="mt-1 text-sm leading-6 text-textSub">需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。</p>
</div> </div>
</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="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="change_email"> <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="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" 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="新登录邮箱">
<button class="ui-btn ui-btn-secondary sm:col-span-2 sm:w-fit">验证并更换邮箱</button> <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> </form>
</section> </section>
<section class="px-6 py-7 sm:px-8"> <section class="px-5 py-7 sm:px-8">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5 text-primary">devices</span> <span class="material-symbols-outlined mt-0.5 text-primary">devices</span>
<div> <div>
@@ -69,10 +70,49 @@
<form method="post" class="mt-5"> <form method="post" class="mt-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="revoke_devices"> <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">撤销所有受信设备</button> <button class="ui-btn ui-btn-secondary w-full sm:w-fit">撤销所有受信设备</button>
</form> </form>
</section> </section>
</div> </div>
</section> </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 %} {% endblock %}
+208 -33
View File
@@ -7,59 +7,234 @@
{% block content %} {% block content %}
<div> <div>
<h1 class="text-3xl font-extrabold">管理台</h1> <h1 class="text-3xl font-extrabold">管理台</h1>
<p class="mt-2 text-sm text-textSub">管理注册状态、密码重置和所有预测记录。</p> <p class="mt-2 text-sm text-textSub">管理用户账号、注册状态和预测记录。</p>
</div>
<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>
<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() }}">
<label class="text-sm">
<input type="checkbox" name="allow_registration" {{ 'checked' if registration_allowed }}>
允许自助注册
</label>
<button class="ui-btn ui-btn-sm ui-btn-primary">保存</button>
</form>
</section>
<section class="rounded-xl border border-line bg-white p-5 shadow-panel">
<h2 class="font-extrabold">邀请注册</h2>
<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 sm:items-center" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="sr-only" for="inviteEmail">受邀邮箱</label>
<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>
</form>
</section>
</div> </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">
<h2 class="font-extrabold">用户注册</h2> <div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<form method="post" action="{{ url_for('main.update_registration_setting') }}" class="mt-3 flex items-center gap-3"> <div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <h2 class="font-extrabold">用户管理</h2>
<label class="text-sm"> <p class="mt-1 text-sm text-textSub">查看用户状态,发送重置通知或撤销受信设备。</p>
<input type="checkbox" name="allow_registration" {{ 'checked' if registration_allowed }}> </div>
允许自助注册 <form method="get" class="flex w-full gap-2 sm:w-auto">
</label> <input
<button class="ui-btn ui-btn-sm ui-btn-primary">保存</button> name="q"
</form> value="{{ user_search }}"
class="min-w-0 flex-1 rounded-lg border border-line px-3 py-2 text-sm sm:w-64"
placeholder="搜索显示名或邮箱"
>
<button class="ui-btn ui-btn-sm ui-btn-secondary">搜索</button>
</form>
</div>
<div class="mt-4 space-y-3 lg:hidden">
{% for user in user_pagination.items %}
<article class="rounded-lg border border-line bg-slate-50 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="truncate font-bold">{{ user.username }}</h3>
<p class="mt-1 break-all text-xs text-textSub">{{ user.email or '未设置邮箱' }}</p>
</div>
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-bold {{ 'bg-successSoft text-successText' if user.is_active_account else 'bg-dangerSoft text-dangerText' }}">
{{ '已启用' if user.is_active_account else '已停用' }}
</span>
</div>
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div><dt class="text-xs text-textSub">邮箱状态</dt><dd class="mt-1 font-semibold">{{ '已验证' if user.email_verified_at else '未验证' }}</dd></div>
<div><dt class="text-xs text-textSub">受信设备</dt><dd class="mt-1 font-semibold">{{ active_device_counts.get(user.id, 0) }} 台</dd></div>
<div class="col-span-2"><dt class="text-xs text-textSub">注册时间</dt><dd class="mt-1 text-textMain">{{ format_datetime(user.created_at) }}</dd></div>
</dl>
<div class="mt-4 grid gap-2 sm:grid-cols-3">
{% if user.email %}
<form method="post" action="{{ url_for('main.admin_password_reset', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<button class="ui-btn ui-btn-compact ui-btn-secondary w-full">重置通知</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('main.admin_revoke_trusted_devices', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<button class="ui-btn ui-btn-compact ui-btn-secondary w-full">撤销设备</button>
</form>
<form method="post" action="{{ url_for('main.admin_update_account_status', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<input type="hidden" name="is_active" value="{{ 'false' if user.is_active_account else 'true' }}">
<button class="ui-btn ui-btn-compact w-full {{ 'ui-btn-secondary' if user.is_active_account else 'ui-btn-primary' }}">{{ '停用账号' if user.is_active_account else '启用账号' }}</button>
</form>
</div>
</article>
{% else %}
<p class="rounded-lg border border-dashed border-line px-3 py-8 text-center text-sm text-textSub">未找到匹配用户。</p>
{% endfor %}
</div>
<div class="mt-4 hidden overflow-x-auto lg:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs font-bold uppercase tracking-[.08em] text-textSub">
<tr>
<th class="px-3 py-3">用户</th>
<th class="px-3 py-3">邮箱状态</th>
<th class="px-3 py-3">账号状态</th>
<th class="px-3 py-3">受信设备</th>
<th class="px-3 py-3">注册时间</th>
<th class="px-3 py-3">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{% for user in user_pagination.items %}
<tr>
<td class="px-3 py-3">
<div class="font-bold">{{ user.username }}</div>
<div class="mt-1 text-xs text-textSub">{{ user.email or '未设置邮箱' }}</div>
</td>
<td class="px-3 py-3">
<span class="rounded-full px-2 py-1 text-xs font-bold {{ 'bg-successSoft text-successText' if user.email_verified_at else 'bg-warnSoft text-warnText' }}">
{{ '已验证' if user.email_verified_at else '未验证' }}
</span>
</td>
<td class="px-3 py-3">
<span class="rounded-full px-2 py-1 text-xs font-bold {{ 'bg-successSoft text-successText' if user.is_active_account else 'bg-dangerSoft text-dangerText' }}">
{{ '已启用' if user.is_active_account else '已停用' }}
</span>
</td>
<td class="px-3 py-3 font-semibold">{{ active_device_counts.get(user.id, 0) }}</td>
<td class="whitespace-nowrap px-3 py-3 text-textSub">{{ format_datetime(user.created_at) }}</td>
<td class="px-3 py-3">
<div class="flex min-w-[260px] flex-wrap gap-2">
{% if user.email %}
<form method="post" action="{{ url_for('main.admin_password_reset', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<button class="ui-btn ui-btn-compact ui-btn-secondary">重置通知</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('main.admin_revoke_trusted_devices', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<button class="ui-btn ui-btn-compact ui-btn-secondary">撤销设备</button>
</form>
<form method="post" action="{{ url_for('main.admin_update_account_status', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_page" value="{{ user_pagination.page }}">
<input type="hidden" name="q" value="{{ user_search }}">
<input type="hidden" name="is_active" value="{{ 'false' if user.is_active_account else 'true' }}">
<button class="ui-btn ui-btn-compact {{ 'ui-btn-secondary' if user.is_active_account else 'ui-btn-primary' }}">
{{ '停用账号' if user.is_active_account else '启用账号' }}
</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-3 py-8 text-center text-sm text-textSub">未找到匹配用户。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if user_pagination.total %}
<nav class="flex flex-col gap-3 border-t border-line px-1 pt-4 text-sm sm:flex-row sm:items-center sm:justify-between" aria-label="用户分页">
<span class="text-textSub">共 {{ user_pagination.total }} 位用户,第 {{ user_pagination.page }} / {{ user_pagination.pages }} 页</span>
<div class="flex gap-2">
<a
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not user_pagination.has_prev }}"
href="{{ url_for('main.admin_dashboard', user_page=user_pagination.prev_num if user_pagination.has_prev else user_pagination.page, q=user_search) }}"
>上一页</a>
<a
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not user_pagination.has_next }}"
href="{{ url_for('main.admin_dashboard', user_page=user_pagination.next_num if user_pagination.has_next else user_pagination.page, q=user_search) }}"
>下一页</a>
</div>
</nav>
{% endif %}
</section> </section>
<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">
<h2 class="font-extrabold">用户密码重置</h2> <h2 class="font-extrabold">上传记录</h2>
<p class="mt-1 text-sm text-textSub">向用户已验证邮箱发送重置通知。</p> <div class="mt-4 space-y-3 lg:hidden">
<div class="mt-4 overflow-x-auto"> {% for record in records %}
<article class="rounded-lg border border-line bg-slate-50 p-4">
<h3 class="break-all font-bold">{{ record.original_filename }}</h3>
<dl class="mt-3 grid gap-2 text-sm">
<div class="flex justify-between gap-4"><dt class="text-textSub">用户</dt><dd class="font-semibold">{{ record.user.username }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-textSub">上传时间</dt><dd class="text-right">{{ format_datetime(record.upload_time) }}</dd></div>
</dl>
<div class="mt-4 grid grid-cols-2 gap-2">
<a class="ui-btn ui-btn-compact ui-btn-secondary w-full" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}" data-download-action data-download-pending="下载中...">
<span class="material-symbols-outlined text-lg">description</span>
原始文件
</a>
<a class="ui-btn ui-btn-compact ui-btn-primary w-full" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}" data-download-action data-download-pending="下载中...">
<span class="material-symbols-outlined text-lg">download</span>
预测结果
</a>
</div>
</article>
{% else %}
<p class="rounded-lg border border-dashed border-line px-3 py-8 text-center text-sm text-textSub">暂无上传记录。</p>
{% endfor %}
</div>
<div class="mt-4 hidden overflow-x-auto lg:block">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead> <thead>
<tr class="border-b border-line text-left"> <tr class="border-b border-line text-left">
<th class="p-2">显示名</th><th class="p-2">邮箱</th><th class="p-2">操作</th> <th class="p-2">用户</th>
<th class="p-2">文件</th>
<th class="p-2">时间</th>
<th class="p-2">下载</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for user in password_reset_users %} {% for record in records %}
<tr class="border-b border-line"> <tr class="border-b border-line">
<td class="p-2">{{ user.username }}</td> <td class="p-2">{{ record.user.username }}</td>
<td class="p-2">{{ user.email or '未设置' }}</td> <td class="p-2">{{ record.original_filename }}</td>
<td class="p-2">{{ format_datetime(record.upload_time) }}</td>
<td class="p-2"> <td class="p-2">
{% if user.email %} <div class="flex min-w-[190px] gap-2">
<form method="post" action="{{ url_for('main.admin_password_reset', user_id=user.id) }}"> <a class="ui-btn ui-btn-compact ui-btn-secondary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}" data-download-action data-download-pending="下载中...">原始文件</a>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <a class="ui-btn ui-btn-compact ui-btn-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}" data-download-action data-download-pending="下载中...">预测结果</a>
<button class="ui-btn ui-btn-sm ui-btn-secondary">发送重置通知</button> </div>
</form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<section class="mt-6 rounded-xl border border-line bg-white p-5 shadow-panel">
<h2 class="font-extrabold">上传记录</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b border-line text-left"><th class="p-2">用户</th><th class="p-2">文件</th><th class="p-2">时间</th></tr></thead>
<tbody>{% for record in records %}<tr class="border-b border-line"><td class="p-2">{{ record.user.username }}</td><td class="p-2">{{ record.original_filename }}</td><td class="p-2">{{ format_datetime(record.upload_time) }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{{ render_pagination(pagination, 'main.admin_dashboard') }} {{ render_pagination(pagination, 'main.admin_dashboard') }}
</section> </section>
{% endblock %} {% endblock %}
+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>
+6 -4
View File
@@ -4,6 +4,8 @@
<title>{% block title %}供水管道健康评估系统{% endblock %}</title> <title>{% block title %}供水管道健康评估系统{% endblock %}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" /> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
@@ -37,8 +39,8 @@
</div>{% endif %} </div>{% endif %}
</div> </div>
</div> </div>
<nav class="md:hidden border-t border-line bg-white"> <nav class="md:hidden border-t border-line bg-white" aria-label="主导航">
<div class="mx-auto max-w-7xl px-2 flex overflow-x-auto text-sm font-semibold"> <div class="mx-auto flex max-w-7xl gap-1 overflow-x-auto px-2 text-sm font-semibold">
<a href="{{ url_for('main.home') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'home' else 'text-slate-500' }}">主页</a> <a href="{{ url_for('main.home') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'home' else 'text-slate-500' }}">主页</a>
<a href="{{ url_for('main.result_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'result' else 'text-slate-500' }}">结果</a> <a href="{{ url_for('main.result_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'result' else 'text-slate-500' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'history' else 'text-slate-500' }}">历史</a> <a href="{{ url_for('main.history_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'history' else 'text-slate-500' }}">历史</a>
@@ -52,7 +54,7 @@
</header> </header>
{% with flashed_messages = get_flashed_messages(with_categories=true) %} {% with flashed_messages = get_flashed_messages(with_categories=true) %}
<div id="alertBox" class="pointer-events-none fixed left-1/2 top-20 z-50 hidden w-[calc(100vw-2rem)] max-w-[430px] -translate-x-1/2 translate-y-3 rounded-lg border bg-white p-4 opacity-0 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:top-24" role="status" aria-live="polite"> <div id="alertBox" class="pointer-events-none fixed left-1/2 top-20 z-50 hidden max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[430px] -translate-x-1/2 translate-y-3 overflow-y-auto rounded-lg border bg-white p-4 opacity-0 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:top-24" role="status" aria-live="polite">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"> <span id="alertIconWrap" class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-xl">priority_high</span> <span id="alertIcon" class="material-symbols-outlined text-xl">priority_high</span>
@@ -74,7 +76,7 @@
</div> </div>
{% endwith %} {% endwith %}
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> <main id="main-content" class="mx-auto w-full max-w-7xl flex-1 px-4 py-5 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+23 -1
View File
@@ -1 +1,23 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">找回密码</h1><p class="mt-2 text-sm text-textSub">输入邮箱后,如账户存在会收到验证码。</p><form method="post" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="email" type="email" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="邮箱"><button class="ui-btn ui-btn-primary w-full">发送验证码</button></form></section>{% endblock %} {% extends "base.html" %}
{% block title %}找回密码{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<h1 class="text-2xl font-extrabold">找回密码</h1>
<p class="mt-2 text-sm text-textSub">输入邮箱后,如账户存在会收到一次性重置链接。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<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 %}
<form method="post" class="mt-5 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="sr-only" for="resetEmail">登录邮箱</label>
<input id="resetEmail" name="email" type="email" required autocomplete="email" class="w-full rounded-lg border border-line px-3 py-2" placeholder="登录邮箱">
<button class="ui-btn ui-btn-primary w-full">发送重置链接</button>
</form>
</section>
{% endblock %}
+6 -6
View File
@@ -10,13 +10,13 @@
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测历史</h1> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测历史</h1>
<p class="mt-2 text-sm text-textSub">查看已上传文件和对应预测报告。</p> <p class="mt-2 text-sm text-textSub">查看已上传文件和对应预测报告。</p>
</div> </div>
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary"> <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary w-full sm:w-fit">
<span class="material-symbols-outlined text-lg">upload_file</span> <span class="material-symbols-outlined text-lg">upload_file</span>
新建分析 新建分析
</a> </a>
</div> </div>
<section class="flex h-[900px] min-w-0 flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel"> <section class="flex min-w-0 flex-col rounded-lg border border-line bg-white shadow-panel lg:h-[900px] lg:overflow-hidden">
<div class="border-b border-line px-5 py-4"> <div class="border-b border-line px-5 py-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-lg font-extrabold">上传记录</h2> <h2 class="text-lg font-extrabold">上传记录</h2>
@@ -25,9 +25,9 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="min-h-0 min-w-0 flex-1 divide-y divide-line overflow-y-auto"> <div class="min-w-0 flex-1 md:divide-y md:divide-line lg:min-h-0 lg:overflow-y-auto">
{% for record in records %} {% for record in records %}
<div class="grid min-h-[76px] min-w-0 gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"> <article class="m-3 grid min-w-0 gap-3 rounded-lg border border-line bg-slate-50 p-4 md:m-0 md:min-h-[76px] md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:rounded-none md:border-0 md:bg-transparent md:px-4 md:py-3">
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<div class="truncate text-sm font-bold">{{ record.original_filename }}</div> <div class="truncate text-sm font-bold">{{ record.original_filename }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-textSub"> <div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-textSub">
@@ -46,9 +46,9 @@
预测结果 预测结果
</a> </a>
</div> </div>
</div> </article>
{% else %} {% else %}
<div class="flex h-full min-h-[520px] flex-col items-center justify-center p-10 text-center"> <div class="flex min-h-[360px] flex-col items-center justify-center p-10 text-center lg:h-full lg:min-h-[520px]">
<span class="material-symbols-outlined text-5xl text-primary">history</span> <span class="material-symbols-outlined text-5xl text-primary">history</span>
<h2 class="mt-4 text-xl font-extrabold">还没有历史记录</h2> <h2 class="mt-4 text-xl font-extrabold">还没有历史记录</h2>
<p class="mt-2 text-sm text-textSub">上传数据并完成预测后,记录会显示在这里。</p> <p class="mt-2 text-sm text-textSub">上传数据并完成预测后,记录会显示在这里。</p>
+11 -11
View File
@@ -9,14 +9,14 @@
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1>
<p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成管道的剩余寿命分析图、健康等级摘要、剩余寿命判断和电子表格预测报告。</p> <p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成管道的剩余寿命分析图、健康等级摘要、剩余寿命判断和电子表格预测报告。</p>
</div> </div>
<a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary" data-download-action data-download-pending="下载中..."> <a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary w-full text-primary sm:w-fit" data-download-action data-download-pending="下载中...">
<span class="material-symbols-outlined text-lg">download</span> <span class="material-symbols-outlined text-lg">download</span>
下载数据模板 下载数据模板
</a> </a>
</div> </div>
<div id="mainGrid" class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]"> <div id="mainGrid" class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<section id="uploadPanel" class="order-1 flex h-[460px] flex-col rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:col-start-1 lg:row-start-1"> <section id="uploadPanel" class="order-1 flex flex-col rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:h-[460px] lg:col-start-1 lg:row-start-1">
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h2 class="text-xl font-extrabold">上传分析文件</h2> <h2 class="text-xl font-extrabold">上传分析文件</h2>
@@ -30,7 +30,7 @@
<form id="predictForm" action="{{ url_for('main.predict') }}" method="post" enctype="multipart/form-data" novalidate class="flex flex-1 flex-col gap-5"> <form id="predictForm" action="{{ url_for('main.predict') }}" method="post" enctype="multipart/form-data" novalidate class="flex flex-1 flex-col gap-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label id="dropZone" class="flex min-h-0 flex-1 cursor-pointer flex-col justify-center rounded-lg border-2 border-dashed border-outline bg-slate-50 px-5 py-8 text-center transition hover:border-primary hover:bg-blue-50/50"> <label id="dropZone" class="flex min-h-[260px] flex-1 cursor-pointer flex-col justify-center rounded-lg border-2 border-dashed border-outline bg-slate-50 px-5 py-8 text-center transition hover:border-primary hover:bg-blue-50/50 lg:min-h-0">
<input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute h-px w-px opacity-0"> <input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute h-px w-px opacity-0">
<span class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white text-primary shadow-panel"> <span class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white text-primary shadow-panel">
<span class="material-symbols-outlined text-3xl">cloud_upload</span> <span class="material-symbols-outlined text-3xl">cloud_upload</span>
@@ -42,7 +42,7 @@
<div class="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-textSub">预测结果会在本页生成摘要,并可进入结果页查看完整报告。</p> <p class="text-xs text-textSub">预测结果会在本页生成摘要,并可进入结果页查看完整报告。</p>
<button id="submitBtn" class="ui-btn ui-btn-primary min-w-[150px]" type="submit"> <button id="submitBtn" class="ui-btn ui-btn-primary w-full sm:w-fit sm:min-w-[150px]" type="submit">
<span id="submitText">分析并预测</span> <span id="submitText">分析并预测</span>
<span id="submitIcon" class="material-symbols-outlined text-lg">analytics</span> <span id="submitIcon" class="material-symbols-outlined text-lg">analytics</span>
</button> </button>
@@ -50,7 +50,7 @@
</form> </form>
</section> </section>
<section id="inlineResult" class="order-3 h-[520px] overflow-hidden rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:col-start-1 lg:row-start-2"> <section id="inlineResult" class="order-3 min-h-[420px] overflow-hidden rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:h-[520px] lg:col-start-1 lg:row-start-2">
<div id="resultPlaceholder" class="flex h-full min-h-0 flex-col"> <div id="resultPlaceholder" class="flex h-full min-h-0 flex-col">
<div> <div>
<h2 class="text-xl font-extrabold">最新预测结果</h2> <h2 class="text-xl font-extrabold">最新预测结果</h2>
@@ -69,12 +69,12 @@
<h2 class="text-xl font-extrabold">最新预测结果</h2> <h2 class="text-xl font-extrabold">最新预测结果</h2>
<p class="mt-1 text-sm text-textSub">已生成图表与电子表格结果文件。</p> <p class="mt-1 text-sm text-textSub">已生成图表与电子表格结果文件。</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="grid gap-2 sm:flex sm:flex-wrap">
<a id="resultPageBtn" href="{{ url_for('main.result_page') }}" class="ui-btn ui-btn-sm ui-btn-secondary"> <a id="resultPageBtn" href="{{ url_for('main.result_page') }}" class="ui-btn ui-btn-sm ui-btn-secondary w-full sm:w-auto">
<span class="material-symbols-outlined text-lg">monitoring</span> <span class="material-symbols-outlined text-lg">monitoring</span>
查看结果页 查看结果页
</a> </a>
<a id="excelBtn" href="#" class="ui-btn ui-btn-sm ui-btn-primary" data-download-action data-download-pending="下载中..."> <a id="excelBtn" href="#" class="ui-btn ui-btn-sm ui-btn-primary w-full sm:w-auto" data-download-action data-download-pending="下载中...">
<span class="material-symbols-outlined text-lg">download</span> <span class="material-symbols-outlined text-lg">download</span>
下载结果表 下载结果表
</a> </a>
@@ -84,7 +84,7 @@
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<div class="flex h-full min-h-0 flex-col rounded-md border border-line bg-slate-50 p-3"> <div class="flex h-full min-h-0 flex-col rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 shrink-0 text-xs font-bold text-textSub">管道的剩余寿命分析图</div> <div class="mb-2 shrink-0 text-xs font-bold text-textSub">管道的剩余寿命分析图</div>
<div class="min-h-0 flex-1 overflow-hidden rounded bg-white"> <div class="min-h-[260px] flex-1 overflow-hidden rounded bg-white lg:min-h-0">
<img id="resultImage" src="" alt="预测图" class="h-full w-full object-contain"> <img id="resultImage" src="" alt="预测图" class="h-full w-full object-contain">
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@
</div> </div>
</section> </section>
<section id="requirementsPanel" class="order-2 h-[460px] rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2 lg:row-start-1"> <section id="requirementsPanel" class="order-2 rounded-lg border border-line bg-white p-5 shadow-panel lg:h-[460px] lg:col-start-2 lg:row-start-1">
<h2 class="flex items-center gap-2 text-lg font-extrabold"> <h2 class="flex items-center gap-2 text-lg font-extrabold">
<span class="material-symbols-outlined text-primary">rule</span> <span class="material-symbols-outlined text-primary">rule</span>
数据要求 数据要求
@@ -118,7 +118,7 @@
</div> </div>
</section> </section>
<section class="order-4 h-[520px] rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2 lg:row-start-2"> <section class="order-4 rounded-lg border border-line bg-white p-5 shadow-panel lg:h-[520px] lg:col-start-2 lg:row-start-2">
<h2 class="flex items-center gap-2 text-lg font-extrabold"> <h2 class="flex items-center gap-2 text-lg font-extrabold">
<span class="material-symbols-outlined text-primary">quick_reference</span> <span class="material-symbols-outlined text-primary">quick_reference</span>
快速入口 快速入口
+45 -115
View File
@@ -4,103 +4,9 @@
<title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title> <title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" /> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<style>
.password-toggle {
position: absolute;
right: .75rem;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: .5rem;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #005EB8;
}
.password-toggle:focus-visible {
outline: 2px solid #005EB8;
outline-offset: 2px;
}
.password-toggle:disabled {
cursor: not-allowed;
opacity: .45;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.captcha-token {
-webkit-user-select: none;
user-select: none;
}
.dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
background-size: 42px 42px;
}
.panel-frame {
box-shadow: none;
border: none;
}
.gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
}
.auth-alert {
pointer-events: none;
opacity: 0;
transform: translateY(.5rem);
}
.auth-alert::before {
content: '';
position: absolute;
left: 24px;
top: -7px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-left: 1px solid currentColor;
border-top: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
@media (min-width: 1280px) {
.auth-alert {
transform: translateX(.75rem);
}
.auth-alert::before {
left: -7px;
top: var(--auth-alert-arrow-top, 44px);
border: 0;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
</style>
</head> </head>
<body class="bg-page min-h-screen text-textMain"> <body class="bg-page min-h-screen text-textMain">
{% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %} {% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %}
@@ -108,6 +14,7 @@
<script> <script>
window.__flashMessages = {{ flashed_messages|tojson }}; window.__flashMessages = {{ flashed_messages|tojson }};
window.__pageNotice = {{ page_notice|tojson }}; window.__pageNotice = {{ page_notice|tojson }};
window.__authErrorField = {{ auth_error_field|tojson }};
</script> </script>
<div class="sr-only" aria-hidden="true"> <div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %} {% for category, message in flashed_messages %}{{ message }}{% endfor %}
@@ -135,7 +42,7 @@
<section id="authPanel" class="relative bg-white flex flex-col justify-between min-h-screen"> <section id="authPanel" class="relative bg-white flex flex-col justify-between min-h-screen">
<div class="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12"> <div class="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12">
<div id="authCard" class="relative w-full max-w-[360px] min-h-[620px] mx-auto"> <div id="authCard" class="relative w-full max-w-[360px] min-h-[620px] mx-auto">
<div id="alertBox" class="auth-alert fixed left-7 right-7 top-6 z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:left-12 sm:right-12 xl:left-auto xl:right-auto xl:w-[320px]" role="status" aria-live="polite"> <div id="alertBox" class="auth-alert fixed z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out" role="status" aria-live="polite">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md"> <span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span> <span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span>
@@ -164,10 +71,10 @@
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form> <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() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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="relative"> <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> <span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</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="请输入邮箱" /> <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>
</div> </div>
@@ -186,14 +93,14 @@
</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">
<div class="relative"> <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> <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>
<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> <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="刷新验证码"> <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> <span class="material-symbols-outlined">refresh</span>
</a> </a>
</div> </div>
@@ -237,14 +144,24 @@
</div> </div>
</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="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>
<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">
<div class="relative"> <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> <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>
<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> <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' }}"> <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> <span class="material-symbols-outlined">refresh</span>
</a> </a>
</div> </div>
@@ -350,6 +267,11 @@
positionAlert(alertAnchor); positionAlert(alertAnchor);
} }
}); });
window.visualViewport?.addEventListener('resize', () => {
if (alertBox && alertBox.classList.contains('is-visible')) {
positionAlert(alertAnchor);
}
});
function markFieldError(field) { function markFieldError(field) {
if (!field) return; if (!field) return;
@@ -366,21 +288,19 @@
form.querySelectorAll('.auth-input-error').forEach(clearFieldError); form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
} }
function fieldForServerMessage(message) { function fieldForServerMessage() {
const activeForm = document.querySelector('[data-auth-form]'); const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null; if (!activeForm) return null;
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]'); return window.__authErrorField
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]'); ? activeForm.querySelector(`[name="${window.__authErrorField}"]`)
if (message.includes('邮箱')) return activeForm.querySelector('input[name="email"]'); : null;
if (message.includes('用户名') || message.includes('显示名')) return activeForm.querySelector('input[name="username"]');
return null;
} }
const flashedMessages = window.__flashMessages || []; const flashedMessages = window.__flashMessages || [];
const pageNotice = window.__pageNotice; const pageNotice = window.__pageNotice;
if (flashedMessages.length) { if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1]; 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); showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') { if (category === 'error') {
markFieldError(serverField); markFieldError(serverField);
@@ -417,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();
} }
}); });
}); });
-623
View File
@@ -1,623 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>重置密码 | 供水管道健康评估系统</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<style>
.reset-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(360px, .82fr) minmax(420px, 1fr);
background: #f3f5f8;
color: #0f172a;
}
.reset-aside {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: space-between;
padding: 56px 64px;
background:
radial-gradient(circle at 1px 1px, rgba(255,255,255,.20) 1px, transparent 0) 0 0 / 34px 34px,
#0f766e;
color: #fff;
}
.reset-brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 800;
}
.reset-aside-main {
max-width: 460px;
}
.reset-kicker {
margin-bottom: 14px;
color: rgba(255,255,255,.72);
font-size: 12px;
font-weight: 800;
letter-spacing: .18em;
text-transform: uppercase;
}
.reset-aside h1 {
margin: 0;
font-size: 46px;
line-height: 1.18;
font-weight: 900;
letter-spacing: 0;
}
.reset-aside p {
margin: 22px 0 0;
max-width: 420px;
color: rgba(255,255,255,.78);
font-size: 15px;
line-height: 1.8;
}
.reset-meta {
display: grid;
gap: 12px;
margin-top: 32px;
}
.reset-meta-item {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255,255,255,.82);
font-size: 13px;
font-weight: 700;
}
.reset-aside-foot {
color: rgba(255,255,255,.58);
font-size: 12px;
}
.reset-panel {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 48px 28px;
background: #fff;
}
.reset-card {
width: 100%;
max-width: 420px;
}
.reset-mobile-brand {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
color: #0f766e;
font-size: 18px;
font-weight: 900;
}
.reset-icon {
display: inline-flex;
width: 54px;
height: 54px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #ccfbf1;
color: #0f766e;
}
.reset-card h2 {
margin: 18px 0 8px;
font-size: 34px;
line-height: 1.2;
font-weight: 900;
letter-spacing: 0;
}
.reset-copy {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.75;
}
.reset-copy strong {
color: #0f172a;
font-weight: 900;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
.auth-alert {
pointer-events: none;
opacity: 0;
transform: translateY(.5rem);
}
.auth-alert::before {
content: '';
position: absolute;
left: 24px;
top: -7px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-left: 1px solid currentColor;
border-top: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
.reset-form {
margin-top: 28px;
display: grid;
gap: 20px;
}
.reset-field label {
display: block;
margin-bottom: 8px;
color: #64748b;
font-size: 11px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
}
.reset-input-wrap {
position: relative;
}
.reset-input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 20px;
}
.reset-input {
width: 100%;
box-sizing: border-box;
border: 1px solid transparent;
border-radius: 12px;
background: #eceff3;
padding: 14px 48px 14px 44px;
color: #0f172a;
font-size: 16px;
line-height: 1.35;
outline: none;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.reset-input:focus {
border-color: #0f766e;
background: #fff;
box-shadow: 0 0 0 3px rgba(15, 118, 110, .14);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #0f766e;
}
.password-toggle:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.reset-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 4px;
}
.reset-expiry {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.reset-submit {
display: inline-flex;
min-width: 154px;
height: 48px;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #fff;
padding: 0 20px;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 16px 30px rgba(15, 118, 110, .20);
transition: background-color .15s ease, box-shadow .15s ease;
}
.reset-submit:hover {
background: #115e59;
box-shadow: 0 12px 24px rgba(15, 118, 110, .20);
}
.reset-submit:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 3px;
}
.reset-login {
display: inline-flex;
width: fit-content;
height: 48px;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 28px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
padding: 0 18px;
font-size: 14px;
font-weight: 800;
text-decoration: none;
transition: border-color .15s ease, color .15s ease, background-color .15s ease;
}
.reset-login:hover {
border-color: #0f766e;
color: #0f766e;
background: #f8fafc;
}
@media (max-width: 900px) {
.reset-page {
display: block;
background: #fff;
}
.reset-aside {
display: none;
}
.reset-panel {
min-height: 100vh;
padding: 32px 22px;
}
.reset-mobile-brand {
display: flex;
}
.reset-card h2 {
font-size: 30px;
}
.reset-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.reset-submit {
width: 100%;
}
.reset-expiry {
text-align: center;
}
}
@media (min-width: 1280px) {
.auth-alert {
transform: translateX(.75rem);
}
.auth-alert::before {
left: -7px;
top: var(--auth-alert-arrow-top, 44px);
border: 0;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
</style>
</head>
<body>
{% with flashed_messages = get_flashed_messages(with_categories=true) %}
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
</script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
</div>
<main class="reset-page">
<aside class="reset-aside" aria-label="系统信息">
<div class="reset-brand">
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div class="reset-aside-main">
<div class="reset-kicker">Password reset</div>
<h1>为账号设置新的访问密码</h1>
<p>使用管理员生成的一次性链接完成密码更新。提交成功后,旧链接会立即失效。</p>
<div class="reset-meta" aria-hidden="true">
<div class="reset-meta-item">
<span class="material-symbols-outlined">link_off</span>
<span>一次性链接</span>
</div>
<div class="reset-meta-item">
<span class="material-symbols-outlined">encrypted</span>
<span>密码本地加密存储</span>
</div>
</div>
</div>
<div class="reset-aside-foot">© {{ now_year() }} 供水管道健康评估系统</div>
</aside>
<section class="reset-panel">
<div id="authCard" class="reset-card relative">
<div id="alertBox" class="auth-alert fixed left-7 right-7 top-6 z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:left-12 sm:right-12 xl:left-auto xl:right-auto xl:w-[320px]" role="status" aria-live="polite">
<div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span>
</span>
<div class="min-w-0 flex-1 pt-0.5">
<div id="alertTitle" class="text-sm font-extrabold text-textMain"></div>
<div id="alertMessage" class="mt-1 text-sm leading-5 text-textSub"></div>
</div>
<button id="alertClose" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" type="button" aria-label="关闭通知">
<span class="material-symbols-outlined text-base">close</span>
</button>
</div>
</div>
<div class="reset-mobile-brand">
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div class="reset-icon">
<span class="material-symbols-outlined text-[28px]">lock_reset</span>
</div>
<h2>重置密码</h2>
<p class="reset-copy">
{% if token_available %}
为账号 <strong>{{ reset_token.user.username }}</strong> 设置新密码。
{% else %}
当前链接不可用,请联系管理员重新生成一次性重置链接。
{% endif %}
</p>
{% if token_available %}
<form method="post" action="{{ url_for('main.password_reset', token=token) }}" class="reset-form" novalidate data-auth-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="reset-field">
<label for="resetPassword">新密码</label>
<div class="reset-input-wrap">
<span class="material-symbols-outlined reset-input-icon">lock</span>
<input id="resetPassword" name="password" type="password" minlength="6" required class="reset-input" placeholder="请输入至少 6 位密码" />
<button class="password-toggle" type="button" data-password-toggle="resetPassword" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div class="reset-field">
<label for="resetPasswordConfirm">确认密码</label>
<div class="reset-input-wrap">
<span class="material-symbols-outlined reset-input-icon">lock</span>
<input id="resetPasswordConfirm" name="password_confirm" type="password" minlength="6" required class="reset-input" placeholder="请再次输入新密码" />
<button class="password-toggle" type="button" data-password-toggle="resetPasswordConfirm" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<div class="reset-actions">
<div class="reset-expiry">链接有效期至<br>{{ format_datetime(reset_token.expires_at) }}</div>
<button class="reset-submit" type="submit">
<span class="material-symbols-outlined text-lg">check</span>
保存新密码
</button>
</div>
</form>
{% else %}
<a href="{{ url_for('main.login') }}" class="reset-login">
<span class="material-symbols-outlined text-lg">arrow_back</span>
返回登录
</a>
{% endif %}
</div>
</section>
</main>
{% endwith %}
<script>
(() => {
const alertBox = document.getElementById('alertBox');
const alertIconWrap = document.getElementById('alertIconWrap');
const alertIcon = document.getElementById('alertIcon');
const alertTitle = document.getElementById('alertTitle');
const alertMessage = document.getElementById('alertMessage');
const alertClose = document.getElementById('alertClose');
const authCard = document.getElementById('authCard');
let alertTimer = null;
let alertHideTimer = null;
let alertAnchor = null;
function hideAlert() {
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
if (!alertBox) return;
alertBox.classList.remove('is-visible');
alertHideTimer = setTimeout(() => {
alertBox.classList.add('hidden');
}, 200);
}
function positionAlert(anchor) {
if (!alertBox || !authCard) return;
const cardRect = authCard.getBoundingClientRect();
const anchorRect = anchor?.getBoundingClientRect();
const useSidePopover = window.matchMedia('(min-width: 1280px)').matches;
if (!useSidePopover) {
alertBox.style.left = '';
alertBox.style.right = '';
alertBox.style.top = '';
alertBox.style.setProperty('--auth-alert-arrow-top', '28px');
return;
}
const width = 320;
const gap = 18;
const viewportPadding = 16;
const desiredLeft = cardRect.right + gap;
const left = Math.min(desiredLeft, window.innerWidth - width - viewportPadding);
const targetCenter = anchorRect ? anchorRect.top + (anchorRect.height / 2) : cardRect.top + 116;
const top = Math.max(viewportPadding, Math.min(targetCenter - 42, window.innerHeight - 140));
const arrowTop = Math.max(22, Math.min(targetCenter - top - 7, 92));
alertBox.style.left = `${left}px`;
alertBox.style.right = 'auto';
alertBox.style.top = `${top}px`;
alertBox.style.setProperty('--auth-alert-arrow-top', `${arrowTop}px`);
}
function showAppNotification(message, type = 'info', title, anchor) {
if (!alertBox || !alertIconWrap || !alertIcon || !alertTitle || !alertMessage) return;
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
alertAnchor = anchor || null;
positionAlert(anchor);
alertBox.classList.remove('hidden', 'border-red-200', 'border-blue-200', 'is-error');
alertIconWrap.classList.remove('bg-dangerSoft', 'text-dangerText', 'bg-blueSoft', 'text-primary');
if (type === 'error') {
alertBox.classList.add('border-red-200', 'is-error');
alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText');
alertIcon.textContent = 'priority_high';
alertTitle.textContent = title || '操作未完成';
} else {
alertBox.classList.add('border-blue-200');
alertIconWrap.classList.add('bg-blueSoft', 'text-primary');
alertIcon.textContent = 'info';
alertTitle.textContent = title || '提示';
}
alertMessage.textContent = message;
requestAnimationFrame(() => {
alertBox.classList.add('is-visible');
});
alertTimer = setTimeout(hideAlert, 10000);
}
if (alertClose) {
alertClose.addEventListener('click', hideAlert);
}
window.addEventListener('resize', () => {
if (alertBox && alertBox.classList.contains('is-visible')) {
positionAlert(alertAnchor);
}
});
function markFieldError(field) {
if (!field) return;
field.classList.add('auth-input-error');
field.setAttribute('aria-invalid', 'true');
}
function clearFieldError(field) {
field.classList.remove('auth-input-error');
field.removeAttribute('aria-invalid');
}
function clearFormErrors(form) {
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
}
function fieldForServerMessage(message) {
const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null;
if (message.includes('两次')) return activeForm.querySelector('input[name="password_confirm"]');
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
return null;
}
const flashedMessages = window.__flashMessages || [];
if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1];
const serverField = category === 'error' ? fieldForServerMessage(message) : null;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') {
markFieldError(serverField);
}
}
document.querySelectorAll('[data-auth-form]').forEach((form) => {
form.querySelectorAll('input').forEach((field) => {
field.addEventListener('input', () => clearFieldError(field));
});
form.addEventListener('submit', (event) => {
clearFormErrors(form);
const fields = Array.from(form.querySelectorAll('input[required]:not(:disabled)'));
const emptyField = fields.find((field) => !field.value.trim());
if (emptyField) {
event.preventDefault();
showAppNotification('密码不能为空', 'error', undefined, emptyField);
markFieldError(emptyField);
emptyField.focus();
return;
}
const shortPassword = fields.find((field) => {
const minLength = Number(field.getAttribute('minlength'));
return minLength > 0 && field.value.length < minLength;
});
if (shortPassword) {
event.preventDefault();
const minLength = shortPassword.getAttribute('minlength');
showAppNotification(`密码至少需要 ${minLength} 位`, 'error', undefined, shortPassword);
markFieldError(shortPassword);
shortPassword.focus();
return;
}
const password = form.querySelector('input[name="password"]');
const passwordConfirm = form.querySelector('input[name="password_confirm"]');
if (password && passwordConfirm && password.value !== passwordConfirm.value) {
event.preventDefault();
showAppNotification('两次输入的密码不一致', 'error', undefined, passwordConfirm);
markFieldError(passwordConfirm);
passwordConfirm.focus();
}
});
});
document.querySelectorAll('[data-password-toggle]').forEach((toggle) => {
const input = document.getElementById(toggle.dataset.passwordToggle);
const icon = toggle.querySelector('.material-symbols-outlined');
if (!input || !icon) return;
toggle.addEventListener('click', () => {
const shouldShow = input.type === 'password';
input.type = shouldShow ? 'text' : 'password';
icon.textContent = shouldShow ? 'visibility_off' : 'visibility';
toggle.setAttribute('aria-label', shouldShow ? '隐藏密码' : '显示密码');
toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
});
});
})();
</script>
</body>
</html>
+3 -3
View File
@@ -9,21 +9,21 @@
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">技术导则参考文档</h1> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">技术导则参考文档</h1>
<p class="mt-2 text-sm text-textSub">供水管道健康状态与剩余寿命评估系统</p> <p class="mt-2 text-sm text-textSub">供水管道健康状态与剩余寿命评估系统</p>
</div> </div>
<a href="{{ url_for('main.reference_pdf') }}" target="_blank" rel="noopener" class="ui-btn ui-btn-primary"> <a href="{{ url_for('main.reference_pdf') }}" target="_blank" rel="noopener" class="ui-btn ui-btn-primary w-full sm:w-fit">
<span class="material-symbols-outlined text-lg">open_in_new</span> <span class="material-symbols-outlined text-lg">open_in_new</span>
新窗口查看 新窗口查看
</a> </a>
</div> </div>
<section class="overflow-hidden rounded-lg border border-line bg-white shadow-panel"> <section class="overflow-hidden rounded-lg border border-line bg-white shadow-panel">
<div class="border-b border-line px-4 py-3 flex items-center justify-between"> <div class="flex flex-col gap-2 border-b border-line px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2 text-sm font-bold"> <div class="flex items-center gap-2 text-sm font-bold">
<span class="material-symbols-outlined text-primary">picture_as_pdf</span> <span class="material-symbols-outlined text-primary">picture_as_pdf</span>
文档预览 文档预览
</div> </div>
<span class="text-xs text-textSub">浏览器不支持内嵌时可使用右上角按钮</span> <span class="text-xs text-textSub">浏览器不支持内嵌时可使用右上角按钮</span>
</div> </div>
<div class="h-[calc(100vh-230px)] min-h-[560px] bg-slate-100"> <div class="h-[65svh] min-h-[400px] bg-slate-100 sm:h-[calc(100vh-230px)] sm:min-h-[560px]">
<object data="{{ url_for('main.reference_pdf') }}#toolbar=1&navpanes=0" type="application/pdf" class="h-full w-full"> <object data="{{ url_for('main.reference_pdf') }}#toolbar=1&navpanes=0" type="application/pdf" class="h-full w-full">
<div class="flex h-full flex-col items-center justify-center gap-3 p-6 text-center"> <div class="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<span class="material-symbols-outlined text-5xl text-primary">picture_as_pdf</span> <span class="material-symbols-outlined text-5xl text-primary">picture_as_pdf</span>
+20
View File
@@ -0,0 +1,20 @@
{% extends "auth_base.html" %}
{% block title %}重置密码{% endblock %}
{% block content %}
<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6">
<span class="material-symbols-outlined text-4xl text-primary" aria-hidden="true">lock_reset</span>
<h1 class="mt-3 text-2xl font-extrabold">设置新密码</h1>
<p class="mt-2 text-sm leading-6 text-textSub">请设置新的登录密码。提交后,其他登录状态和受信设备将被撤销。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<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 %}
<form method="post" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="block text-sm font-semibold">新密码<input name="password" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="12 位以上,包含大小写、数字和特殊字符"></label>
<label class="block text-sm font-semibold">确认新密码<input name="password_confirm" type="password" required minlength="12" autocomplete="new-password" class="mt-2 w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"></label>
<button class="ui-btn ui-btn-primary w-full">保存新密码</button>
</form>
</section>
{% endblock %}
+5 -5
View File
@@ -10,12 +10,12 @@
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测结果</h1> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测结果</h1>
<p class="mt-2 text-sm text-textSub">文件:<span class="font-semibold text-textMain">{{ result.original_filename }}</span> · 生成于 {{ result.generated_at }}</p> <p class="mt-2 text-sm text-textSub">文件:<span class="font-semibold text-textMain">{{ result.original_filename }}</span> · 生成于 {{ result.generated_at }}</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="grid gap-3 sm:flex sm:flex-wrap">
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary"> <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary w-full sm:w-auto">
<span class="material-symbols-outlined text-lg">refresh</span> <span class="material-symbols-outlined text-lg">refresh</span>
重新分析 重新分析
</a> </a>
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-primary" data-download-action data-download-pending="导出中..."> <a href="{{ result.excel_url }}" class="ui-btn ui-btn-primary w-full sm:w-auto" data-download-action data-download-pending="导出中...">
<span class="material-symbols-outlined text-lg">download</span> <span class="material-symbols-outlined text-lg">download</span>
导出结果表 导出结果表
</a> </a>
@@ -28,7 +28,7 @@
<h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2> <h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2>
<p class="mt-1 text-sm text-textSub">管道剩余寿命随时间变化的分析曲线。</p> <p class="mt-1 text-sm text-textSub">管道剩余寿命随时间变化的分析曲线。</p>
</div> </div>
<div class="flex min-h-[360px] flex-1 items-center justify-center rounded-md border border-line bg-slate-50 p-3"> <div class="flex min-h-[260px] flex-1 items-center justify-center rounded-md border border-line bg-slate-50 p-3 sm:min-h-[360px]">
<img src="{{ result.image_url }}" alt="管道的剩余寿命分析图" class="max-h-full w-full rounded bg-white object-contain"> <img src="{{ result.image_url }}" alt="管道的剩余寿命分析图" class="max-h-full w-full rounded bg-white object-contain">
</div> </div>
</section> </section>
@@ -63,7 +63,7 @@
<span class="material-symbols-outlined text-5xl text-primary">monitoring</span> <span class="material-symbols-outlined text-5xl text-primary">monitoring</span>
<h1 class="mt-4 text-2xl font-extrabold">当前还没有预测结果</h1> <h1 class="mt-4 text-2xl font-extrabold">当前还没有预测结果</h1>
<p class="mt-2 text-sm text-textSub">请先从主页上传文件并运行预测。</p> <p class="mt-2 text-sm text-textSub">请先从主页上传文件并运行预测。</p>
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary mt-6"> <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary mt-6 w-full sm:w-fit">
<span class="material-symbols-outlined text-lg">upload_file</span> <span class="material-symbols-outlined text-lg">upload_file</span>
去上传数据 去上传数据
</a> </a>
+1 -1
View File
@@ -1 +1 @@
{% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-6 shadow-panel"><h1 class="text-2xl font-extrabold">{{ title }}</h1><p class="mt-2 text-sm text-textSub">12 至 128 位,须包含大写字母、小写字母、数字和特殊字符,且不能含空格。</p>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" action="{{ action }}" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"><button class="ui-btn ui-btn-primary w-full">保存密码</button></form></section>{% endblock %} {% extends "base.html" %}{% block content %}<section class="mx-auto max-w-md rounded-xl border border-line bg-white p-5 shadow-panel sm:p-6"><h1 class="text-2xl font-extrabold">{{ title }}</h1><p class="mt-2 text-sm text-textSub">12 至 128 位,须包含大写字母、小写字母、数字和特殊字符,且不能含空格。</p>{% with messages=get_flashed_messages(with_categories=true) %}{% for c,m in messages %}<p class="mt-3 text-sm text-dangerText">{{ m }}</p>{% endfor %}{% endwith %}<form method="post" action="{{ action }}" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="password" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="新密码"><input name="password_confirm" type="password" minlength="12" required class="w-full rounded-lg border border-line px-3 py-2" placeholder="再次输入新密码"><button class="ui-btn ui-btn-primary w-full">保存密码</button></form></section>{% endblock %}
+22 -8
View File
@@ -4,18 +4,20 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} | 供水管道健康评估系统</title> <title>{{ title }} | 供水管道健康评估系统</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"> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet">
</head> </head>
<body class="min-h-screen bg-page text-textMain"> <body class="min-h-screen bg-page text-textMain">
<main class="mx-auto flex min-h-screen max-w-md items-center px-5"> <main class="mx-auto flex min-h-screen max-w-md items-center px-4 py-5 sm:px-5">
<section class="w-full rounded-2xl border border-line bg-white p-7 shadow-panel"> <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> <span class="material-symbols-outlined text-4xl text-primary">mark_email_read</span>
<h1 class="mt-3 text-2xl font-extrabold">{{ title }}</h1> <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) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% 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 %} {% endfor %}
{% endwith %} {% endwith %}
@@ -23,22 +25,34 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input id="verificationCode" type="hidden" name="code" value=""> <input id="verificationCode" type="hidden" name="code" value="">
<fieldset> <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-2" id="codeInputs"> <div class="mt-3 grid grid-cols-6 gap-1.5 sm:gap-2" id="codeInputs">
{% for index in range(6) %} {% for index in range(6) %}
<input <input
type="text" type="text"
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
maxlength="1" maxlength="1"
aria-label="验证码第 {{ index + 1 }} 位" aria-label="邮箱验证码第 {{ index + 1 }} 位"
class="h-12 min-w-0 rounded-lg border border-line text-center text-xl font-bold tracking-wide focus:border-primary focus:ring-primary" 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 data-code-digit
{% if index == 0 %}autofocus{% endif %} {% if index == 0 %}autofocus{% endif %}
> >
{% endfor %} {% endfor %}
</div> </div>
</fieldset> </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> <button id="verifyButton" class="ui-btn ui-btn-primary mt-6 w-full" type="submit">验证并继续</button>
</form> </form>
+255 -14
View File
@@ -5,13 +5,15 @@ import unittest
from datetime import timedelta from datetime import timedelta
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
from urllib.parse import urlparse
from flask import session from flask import session
from app import create_app from app import create_app
from app.config import Config from app.config import Config
from app.email import EmailDeliveryError
from app.extensions import db from app.extensions import db
from app.models import EmailVerificationCode, TrustedDevice, User from app.models import EmailVerificationCode, PasswordResetToken, RegistrationInvitation, TrustedDevice, User
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
from app.time_utils import utc_now from app.time_utils import utc_now
@@ -39,6 +41,8 @@ class EmailAuthenticationTest(unittest.TestCase):
self.assertEqual(html.count("data-code-digit\n"), 6) self.assertEqual(html.count("data-code-digit\n"), 6)
self.assertIn("60 秒后可重新发送", html) self.assertIn("60 秒后可重新发送", html)
self.assertIn('id="resendButton"', html) self.assertIn('id="resendButton"', html)
self.assertIn("al****ce@example.com", html)
self.assertNotIn("alice@example.com", html)
def test_fresh_authorization_is_bound_to_user_and_expires(self): def test_fresh_authorization_is_bound_to_user_and_expires(self):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
@@ -60,6 +64,28 @@ class EmailAuthenticationTest(unittest.TestCase):
).isoformat() ).isoformat()
self.assertFalse(has_fresh_authorization(user, "password_change")) self.assertFalse(has_fresh_authorization(user, "password_change"))
def test_account_security_uses_custom_email_validation_and_password_autofill(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
user_id = user.id
client = app.test_client()
with app.app_context():
self.login_as(client, db.session.get(User, user_id))
response = client.get("/account/security")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertIn('novalidate data-change-email-form', html)
self.assertIn('autocomplete="current-password"', html)
self.assertIn('autocomplete="email"', html)
self.assertIn('请输入有效的新登录邮箱。', html)
def create_app(self, directory: str): def create_app(self, directory: str):
class TestConfig(Config): class TestConfig(Config):
TESTING = True TESTING = True
@@ -82,6 +108,51 @@ class EmailAuthenticationTest(unittest.TestCase):
data["csrf_token"] = self.csrf(page) data["csrf_token"] = self.csrf(page)
return client.post(path, data=data) return client.post(path, data=data)
def login_as(self, client, user: User) -> None:
with client.session_transaction() as state:
state["_user_id"] = str(user.id)
state["_fresh"] = True
state["auth_version"] = user.auth_version
def test_admin_user_list_is_paginated_and_searchable(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
admin = User(
username="Admin",
email="admin@example.com",
is_admin=True,
is_active_account=True,
)
admin.set_password("Password-1234!")
db.session.add(admin)
for index in range(21):
user = User(
username=f"User{index:02d}",
email=f"user{index:02d}@example.com",
is_active_account=True,
)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
admin_id = admin.id
client = app.test_client()
with app.app_context():
self.login_as(client, db.session.get(User, admin_id))
response = client.get("/admin?user_page=2")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertIn("共 21 位用户,第 2 / 2 页", html)
self.assertIn("User00", html)
self.assertNotIn("User20", html)
response = client.get("/admin?q=user20@example.com")
html = response.get_data(as_text=True)
self.assertIn("共 1 位用户,第 1 / 1 页", html)
self.assertIn("User20", html)
@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_registration_requires_and_consumes_email_code(self, _random, _send): def test_registration_requires_and_consumes_email_code(self, _random, _send):
@@ -89,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()
@@ -98,7 +169,143 @@ class EmailAuthenticationTest(unittest.TestCase):
verify = client.get("/verify/register") verify = client.get("/verify/register")
response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"}) response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account) self.assertEqual(response.location, "/home")
self.assertEqual(client.get("/home").status_code, 200)
with app.app_context():
self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account)
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.secrets.randbelow", return_value=123456)
def test_login_accepts_username_or_email(self, _random, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
for identifier in ("Alice", "alice@example.com"):
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": identifier,
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.location, "/verify/login")
with app.app_context():
EmailVerificationCode.query.delete()
db.session.commit()
def test_login_reports_verification_code_cooldown(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.flush()
db.session.add(EmailVerificationCode(
email=user.email,
purpose="login",
code_hash="a" * 64,
expires_at=utc_now() + timedelta(minutes=10),
requested_ip="127.0.0.1",
))
db.session.commit()
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "/verify/login")
with app.app_context():
self.assertEqual(EmailVerificationCode.query.count(), 1)
verify_page = client.get("/verify/login")
html = verify_page.get_data(as_text=True)
self.assertIn("邮箱二次认证", html)
self.assertIn("邮箱验证码已发送,请输入验证码完成二次认证。", html)
self.assertIn('name="trust_device"', html)
def test_invalid_graphic_captcha_does_not_start_email_verification(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
client = app.test_client()
page = client.get("/login")
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": "WRONG",
})
self.assertEqual(response.status_code, 400)
self.assertIn("图形验证码错误", response.get_data(as_text=True))
self.assertIn('window.__authErrorField = "captcha"', response.get_data(as_text=True))
with app.app_context():
self.assertEqual(EmailVerificationCode.query.count(), 0)
@patch("app.routes.send_transactional_email", side_effect=EmailDeliveryError("delivery failed"))
def test_email_delivery_failure_does_not_mark_graphic_captcha(self, _send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
client = app.test_client()
page = client.get("/login")
with client.session_transaction() as state:
captcha = state["captcha"]
response = client.post("/login", data={
"csrf_token": self.csrf(page), "identifier": "Alice",
"password": "Password-1234!", "captcha": captcha,
})
self.assertEqual(response.status_code, 503)
self.assertIn("验证码发送失败,请稍后重试。", response.get_data(as_text=True))
self.assertIn("window.__authErrorField = null", response.get_data(as_text=True))
@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)
@@ -109,16 +316,15 @@ class EmailAuthenticationTest(unittest.TestCase):
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit() user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
client = app.test_client(); page = client.get("/login") client = app.test_client(); page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"] with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha}) response = client.post("/login", data={"csrf_token": self.csrf(page), "identifier": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
self.assertEqual(response.location, "/verify/login") self.assertEqual(response.location, "/verify/login")
verify = client.get("/verify/login") verify = client.get("/verify/login")
response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456"}) response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456", "trust_device": "on"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1) with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
@patch("app.routes.send_transactional_email") @patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456) def test_password_reset_link_revokes_trusted_devices(self, send):
def test_password_reset_revokes_trusted_devices(self, _random, _send):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
app = self.create_app(directory) app = self.create_app(directory)
with app.app_context(): with app.app_context():
@@ -126,15 +332,50 @@ class EmailAuthenticationTest(unittest.TestCase):
client = app.test_client() client = app.test_client()
response = self.form(client, "/forgot-password", email="alice@example.com") response = self.form(client, "/forgot-password", email="alice@example.com")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
verify = client.get("/verify/reset") self.assertEqual(response.location, "/forgot-password")
response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"}) self.assertEqual(send.call_count, 1)
self.assertEqual(response.location, "/set-password") html = send.call_args.kwargs["html"]
page = client.get("/set-password") reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"}) 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!"})
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()
self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2) self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2); self.assertIsNotNone(PasswordResetToken.query.one().used_at)
@patch("app.routes.send_transactional_email")
def test_admin_can_invite_user_while_self_registration_is_disabled(self, send):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
admin = User(username="Admin", email="admin@example.com", is_admin=True, is_active_account=True)
admin.set_password("Password-1234!")
db.session.add(admin)
db.session.commit()
admin_id = admin.id
client = app.test_client()
with app.app_context():
self.login_as(client, db.session.get(User, admin_id))
admin_page = client.get("/admin")
response = client.post(
"/admin/invitations",
data={"csrf_token": self.csrf(admin_page), "email": "invitee@example.com"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(send.call_count, 1)
with app.app_context():
self.assertEqual(RegistrationInvitation.query.count(), 1)
invite_html = send.call_args.kwargs["html"]
invite_path = urlparse(re.search(r'href="([^"]+)"', invite_html).group(1)).path
page = client.get(invite_path)
response = client.post(invite_path, data={"csrf_token": self.csrf(page), "username": "Invited User", "password": "Invited-password-1234!", "password_confirm": "Invited-password-1234!"})
self.assertEqual(response.status_code, 302)
with app.app_context():
invited = User.query.filter_by(email="invitee@example.com").one()
self.assertTrue(invited.is_active_account)
self.assertIsNotNone(invited.email_verified_at)
self.assertIsNotNone(RegistrationInvitation.query.one().used_at)
@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)
@@ -145,7 +386,7 @@ class EmailAuthenticationTest(unittest.TestCase):
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit() user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
page = client.get("/login") page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"] with client.session_transaction() as state: captcha = state["captcha"]
client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha}) client.post("/login", data={"csrf_token": self.csrf(page), "identifier": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
for _ in range(5): for _ in range(5):
page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"}) page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"})
page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"}) page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"})
+12
View File
@@ -9,6 +9,7 @@ from app.config import Config
from app.email import ( from app.email import (
EmailConfigurationError, EmailConfigurationError,
password_reset_notice_email, password_reset_notice_email,
registration_invitation_email,
send_transactional_email, send_transactional_email,
verification_code_email, verification_code_email,
) )
@@ -31,10 +32,21 @@ class TransactionalEmailTest(unittest.TestCase):
html = password_reset_notice_email( html = password_reset_notice_email(
username="<管理员>", username="<管理员>",
reset_url="https://example.com/reset?x=1&y=2", reset_url="https://example.com/reset?x=1&y=2",
minutes=10,
) )
self.assertIn("&lt;管理员&gt;", html) self.assertIn("&lt;管理员&gt;", html)
self.assertIn("x=1&amp;y=2", html) self.assertIn("x=1&amp;y=2", html)
def test_invitation_email_has_one_time_registration_copy(self):
html = registration_invitation_email(
invitation_url="https://example.com/invite/token",
minutes=10,
)
self.assertIn("接受邀请并注册", html)
self.assertIn("仅可使用一次", html)
self.assertIn("https://example.com/invite/token", html)
def create_test_app(self, temp_dir: str, *, configured: bool): def create_test_app(self, temp_dir: str, *, configured: bool):
class TestConfig(Config): class TestConfig(Config):
TESTING = True TESTING = True