From 87c792db44734efcb0a69f9b716a70d96447b30b Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 4 Aug 2026 18:24:05 +0800 Subject: [PATCH] feat: refine email verification login flow --- .env.example | 29 ++++--- app/routes.py | 125 ++++++++++++++++++++-------- static/css/app.css | 2 +- templates/account_security.html | 50 +++++++++-- templates/login.html | 35 ++++---- templates/verification.html | 20 ++++- tests/test_auth_registration.py | 142 +++++++++++++++++++++++++++++++- 7 files changed, 323 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 629da3a..e7d86cc 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,47 @@ # 环境变量模板:复制为 .env 后填写实际值。不要提交 .env 或 .env.local。 -# 优先级:系统环境变量 > .env.local > .env。 +# 配置优先级:系统环境变量 > .env.local > .env。 # ==================== 运行模式 ==================== # APP_ENV:production(生产)/ development(开发)。 +# DEBUG:是否开启 Flask 调试与热重载;生产必须为 false。 APP_ENV=production -# 是否开启 Flask 调试与热重载。生产必须为 false。 DEBUG=false + # ==================== 会话与基础安全 ==================== -# SECRET_KEY:会话加密密钥。执行 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 # ==================== 管理员初始化 ==================== # 首次启动时创建管理员;之后不会用这些值覆盖管理员密码。 ADMIN_USERNAME=admin ADMIN_PASSWORD=replace-with-a-strong-password -ADMIN_EMAIL=admin@waternetwork.cn +ADMIN_EMAIL= # ==================== 数据与文件 ==================== -# DATABASE_URL:数据库连接。默认使用项目 data 目录中的 SQLite 文件。 +# DATABASE_URL:Docker 环境下使用 /app/data 中的 SQLite 文件。 DATABASE_URL=sqlite:////app/data/pipe_survival.db -# 页面展示的时区。 +# APP_TIMEZONE:页面与导出文件显示时间使用的时区。 APP_TIMEZONE=Asia/Shanghai - -# 单个上传文件最大字节数,16 MiB = 16777216。 +# MAX_UPLOAD_BYTES:单次上传文件最大字节数,16 MiB = 16777216。 MAX_UPLOAD_BYTES=16777216 -# 模型核心目录。 +# FUSION_MODEL_CORE_DIR:Docker 中的模型核心目录。 FUSION_MODEL_CORE_DIR=/app/model_core + # ==================== 注册与邮件服务 ==================== # ALLOW_REGISTRATION:是否允许公开自助注册。 ALLOW_REGISTRATION=false - -# Resend 邮件服务。发件地址必须已在 Resend 验证。 +# Resend 邮件服务:发件地址必须已在 Resend 验证。 RESEND_API_KEY=re_xxxxxxxxx RESEND_FROM_EMAIL=no-reply@waternetwork.cn # ==================== 登录认证与会话期限 ==================== -# 邮件验证码:有效期(分钟)、重发间隔(秒)、最大尝试次数。 +# 邮件验证码:有效期(分钟)、重发间隔(秒)和最大尝试次数。 EMAIL_CODE_MINUTES=10 EMAIL_CODE_RESEND_SECONDS=60 EMAIL_CODE_MAX_ATTEMPTS=5 -# 邮箱验证码通过后,用于改密和找回密码的短时授权(分钟)。 +# FRESH_AUTH_MINUTES:改密、重置密码等敏感操作的短时授权有效期(分钟)。 FRESH_AUTH_MINUTES=5 -# 受信设备的有效期(天)。 +# TRUSTED_DEVICE_DAYS:受信设备有效期(天)。 TRUSTED_DEVICE_DAYS=30 - # SESSION_COOKIE_SECURE:HTTPS 下必须为 true;本地 HTTP 开发环境设为 false。 SESSION_COOKIE_SECURE=true diff --git a/app/routes.py b/app/routes.py index c0a94d5..1819d46 100644 --- a/app/routes.py +++ b/app/routes.py @@ -103,8 +103,18 @@ def consume_fresh_authorization(purpose: str) -> None: session.pop(f"fresh_auth_{purpose}", None) -def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""): - return render_template("login.html", mode=mode, captcha=captcha), status_code +def render_auth_template( + 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: @@ -112,9 +122,14 @@ def refresh_captcha() -> str: 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") - 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: @@ -192,12 +207,17 @@ def code_hash(email: str, purpose: str, code: str) -> str: 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() resend_at = now - timedelta(seconds=current_app.config["EMAIL_CODE_RESEND_SECONDS"]) recent = ( 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() ) ip = request.remote_addr or "" @@ -205,8 +225,10 @@ def issue_email_code(email: str, purpose: str) -> bool: EmailVerificationCode.created_at >= now - timedelta(hours=1), EmailVerificationCode.requested_ip == ip, ).count() - if recent or hourly >= 20: - return False + if recent: + return "resend_wait" + if hourly >= 20: + return "rate_limited" EmailVerificationCode.query.filter_by(email=email, purpose=purpose, used_at=None).update({"used_at": now}) code = f"{secrets.randbelow(1_000_000):06d}" record = EmailVerificationCode( @@ -221,7 +243,7 @@ def issue_email_code(email: str, purpose: str) -> bool: try: send_transactional_email( to=email, - subject=f"供水管道健康评估系统:{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", + subject=f"【管道健康】{EMAIL_CODE_LABELS.get(purpose, '身份验证')}验证码", html=verification_code_email( code=code, minutes=current_app.config["EMAIL_CODE_MINUTES"], @@ -231,8 +253,16 @@ def issue_email_code(email: str, purpose: str) -> bool: except (EmailConfigurationError, EmailDeliveryError): db.session.delete(record) db.session.commit() - return False - return True + return "delivery_failed" + 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: @@ -318,17 +348,26 @@ def login(): return redirect(url_for("main.home")) if request.method == "GET": return render_auth_template("login", captcha=refresh_captcha()) - email, password = normal_email(request.form.get("email", "")), request.form.get("password", "") - if not captcha_is_valid(): return render_auth_error("login", "验证码错误") - user = User.query.filter_by(email=email).first() + identifier, password = request.form.get("identifier", "").strip(), request.form.get("password", "") + if not captcha_is_valid(): + 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): - return render_auth_error("login", "邮箱或密码错误") + return render_auth_error("login", "用户名、邮箱或密码错误") remember = bool(request.form.get("remember")) if trusted_device_for(user): return login_response(user, remember) - session.update(pending_email=email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") - if not issue_email_code(email, "login"): - return render_auth_error("login", "验证码发送失败,请稍后重试", 503) + session.update(pending_email=user.email, pending_user_id=user.id, pending_remember=remember, pending_purpose="login") + if reason := issue_email_code(user.email, "login"): + 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")) @@ -336,16 +375,24 @@ def login(): def register(): 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", "") - if not captcha_is_valid(): return render_auth_error("register", "验证码错误") + 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 username or len(username) > 100: return render_auth_error("register", "显示名不能为空且不能超过100个字符") - if not valid_email(email): return render_auth_error("register", "请输入有效的邮箱地址") - if not valid_password(password): return render_auth_error("register", PASSWORD_RULE_MESSAGE) + if not username or len(username) > 100: + return render_auth_error("register", "显示名不能为空且不能超过100个字符", auth_error_field="username") + 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 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.set_password(password); db.session.add(user); db.session.commit() 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")) @@ -353,24 +400,33 @@ def register(): def verify_email(purpose: str): if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: 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", "") 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")) if not user or (purpose != "change_email_new" and user.email != email): abort(400) 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": - remember = bool(session.get("pending_remember")); session.clear(); return login_response(user, remember, trust_device=True) + remember = bool(session.get("pending_remember")) + trust_device = bool(request.form.get("trust_device")) + session.clear() + return login_response(user, remember, trust_device=trust_device) if purpose == "reset": grant_fresh_authorization(user, "password_reset") return redirect(url_for("main.set_password")) if purpose == "change_email_old": new_email = session.get("new_email", "") session.update(pending_email=new_email, pending_purpose="change_email_new") - if not issue_email_code(new_email, "change_email_new"): - flash("新邮箱验证码发送失败,请稍后重试。", "error"); return redirect(url_for("main.account_security")) + if reason := issue_email_code(new_email, "change_email_new"): + 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")) if purpose == "change_email_new": if User.query.filter(User.email == email, User.id != user.id).first(): @@ -384,7 +440,8 @@ def verify_email(purpose: str): def resend_code(purpose: str): if purpose not in EMAIL_CODE_PURPOSES or session.get("pending_purpose") != purpose: 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") return redirect(url_for("main.verify_email", purpose=purpose)) @@ -437,10 +494,12 @@ def account_security(): if not valid_email(new_email) or User.query.filter_by(email=new_email).first(): 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) - 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")) 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")) @@ -514,7 +573,7 @@ def admin_password_reset(user_id: int): try: send_transactional_email( to=user.email, - subject="供水管道健康评估系统:请重置密码", + subject="【管道健康】密码重置通知", html=password_reset_notice_email( username=user.username, reset_url=url_for("main.forgot_password", _external=True), diff --git a/static/css/app.css b/static/css/app.css index 2dcc134..d791b2c 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1,3 +1,3 @@ @font-face{font-family:Material Symbols Outlined;font-style:normal;font-weight:400;font-display:block;src:url(../fonts/material-symbols-outlined.ttf) format("truetype")}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}body,h1,h2,h3,h4{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.material-symbols-outlined{font-family:Material Symbols Outlined;font-weight:400;font-style:normal;font-size:24px;letter-spacing:normal;text-transform:none;width:1em;height:1em;min-width:1em;flex:0 0 auto;overflow:hidden;word-wrap:normal;direction:ltr;vertical-align:middle;font-variation-settings:"FILL" 0,"wght" 400,"GRAD" 0,"opsz" 24}.material-symbols-outlined,.ui-btn{line-height:1;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap}.ui-btn{box-sizing:border-box;min-height:44px;height:44px;gap:.5rem;border-radius:.375rem;border:1px solid transparent;padding:0 1rem;font-size:.875rem;font-weight:700;transition:background-color .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease}.ui-btn-lg{min-height:52px;height:52px;font-size:.875rem}.ui-btn-sm{min-height:40px;height:40px;padding:0 .75rem}.ui-btn-compact{min-height:34px;height:34px;padding:0 .65rem;gap:.35rem;font-size:.8125rem}.ui-btn-field{min-height:50px;height:50px}.ui-btn-primary{background:#005eb8;color:#fff;box-shadow:0 18px 34px rgba(15,23,42,.06)}.ui-btn-primary:hover{background:#0c4188}.ui-btn-secondary{border-color:#e2e8f0;background:#fff;color:#334155;box-shadow:0 1px 2px rgba(15,23,42,.04)}.ui-btn-secondary:hover{border-color:#005eb8;color:#005eb8}.ui-btn:disabled,.ui-btn[aria-disabled=true]{cursor:not-allowed;box-shadow:none;opacity:.7}.ui-btn[aria-busy=true]{pointer-events:none}.ui-btn-primary:disabled{background:#cbd5e1}.ui-btn .material-symbols-outlined,.ui-btn-icon{width:20px;height:20px;flex:0 0 20px;font-size:20px}.ui-icon-btn{display:inline-flex;width:28px;height:28px;flex:0 0 28px;justify-content:center;transition:background-color .15s ease,color .15s ease}.ui-action-row,.ui-icon-btn{box-sizing:border-box;align-items:center;border-radius:.375rem;line-height:1}.ui-action-row{display:flex;min-height:44px;justify-content:space-between;gap:.75rem;border:1px solid #e2e8f0;padding:0 .75rem;font-size:.875rem;font-weight:700;transition:border-color .15s ease,color .15s ease}.ui-action-row:hover{border-color:#005eb8;color:#005eb8}.spinner{display:inline-flex;width:20px;height:20px;flex:0 0 20px;border-radius:9999px;border:2px solid hsla(0,0%,100%,.35);border-top-color:#fff;animation:spin .75s linear infinite}.ui-btn-secondary .spinner{border-color:#005eb8 rgba(0,94,184,.22) rgba(0,94,184,.22)}.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;-moz-user-select:none;user-select:none}.gradient-board{background:linear-gradient(180deg,#2455a3,#123e7d)}.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;border-left:1px solid;background:#fff;color:#bfdbfe;content:""}.auth-alert.is-error:before{color:#fecaca}.auth-alert.is-visible{pointer-events:auto;opacity:1;transform:translate(-50%)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.left-4{left:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-20{top:5rem}.z-30{z-index:30}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.col-span-2{grid-column:span 2/span 2}.m-3{margin:.75rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mr-1{margin-right:-.25rem}.-mt-1{margin-top:-.25rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-7{margin-bottom:1.75rem}.mb-8{margin-bottom:2rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[50px\]{height:50px}.h-\[65svh\]{height:65svh}.h-full{height:100%}.h-px{height:1px}.max-h-\[calc\(100dvh-2rem\)\]{max-height:calc(100dvh - 2rem)}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-\[260px\]{min-height:260px}.min-h-\[360px\]{min-height:360px}.min-h-\[400px\]{min-height:400px}.min-h-\[420px\]{min-height:420px}.min-h-\[620px\]{min-height:620px}.min-h-screen{min-height:100vh}.w-14{width:3.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[calc\(100vw-2rem\)\]{width:calc(100vw - 2rem)}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-10{min-width:2.5rem}.min-w-\[190px\]{min-width:190px}.min-w-\[260px\]{min-width:260px}.min-w-full{min-width:100%}.max-w-3xl{max-width:48rem}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[360px\]{max-width:360px}.max-w-\[430px\]{max-width:430px}.max-w-\[440px\]{max-width:440px}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-y-0{--tw-translate-y:0px}.translate-y-0,.translate-y-3{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-3{--tw-translate-y:0.75rem}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[1fr_92px_44px\]{grid-template-columns:1fr 92px 44px}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(226 232 240/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-100{--tw-border-opacity:1;border-color:rgb(254 243 199/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-line{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-orange-100{--tw-border-opacity:1;border-color:rgb(255 237 213/var(--tw-border-opacity,1))}.border-outline{--tw-border-opacity:1;border-color:rgb(199 206 216/var(--tw-border-opacity,1))}.border-primary{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[\#eceff3\]{--tw-bg-opacity:1;background-color:rgb(236 239 243/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blueSoft{--tw-bg-opacity:1;background-color:rgb(234 243 255/var(--tw-bg-opacity,1))}.bg-dangerSoft{--tw-bg-opacity:1;background-color:rgb(255 240 240/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-page{--tw-bg-opacity:1;background-color:rgb(243 245 248/var(--tw-bg-opacity,1))}.bg-primary{--tw-bg-opacity:1;background-color:rgb(0 94 184/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-successSoft{--tw-bg-opacity:1;background-color:rgb(233 248 238/var(--tw-bg-opacity,1))}.bg-warnSoft{--tw-bg-opacity:1;background-color:rgb(255 244 232/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-16{padding-left:4rem;padding-right:4rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-7{padding-top:1.75rem;padding-bottom:1.75rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pl-11{padding-left:2.75rem}.pr-12{padding-right:3rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-\[32px\]{font-size:32px}.text-\[44px\]{font-size:44px}.text-\[52px\]{font-size:52px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-\[1\.5\]{line-height:1.5}.tracking-\[\.08em\]{letter-spacing:.08em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-dangerText{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:rgb(0 94 184/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-successText{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-textMain{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-textSub{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-warnText{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/75{color:hsla(0,0%,100%,.75)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.shadow-\[0_18px_45px_rgba\(15\2c 23\2c 42\2c \.16\)\]{--tw-shadow:0 18px 45px rgba(15,23,42,.16);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color)}.shadow-\[0_18px_45px_rgba\(15\2c 23\2c 42\2c \.16\)\],.shadow-panel{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-panel{--tw-shadow:0 1px 2px rgba(15,23,42,.04);--tw-shadow-colored:0 1px 2px var(--tw-shadow-color)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-red-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 226 226/var(--tw-ring-opacity,1))}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@keyframes spin{to{transform:rotate(1turn)}}@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;border-left:1px solid}.auth-alert.is-visible{transform:translateX(0)}}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.hover\:bg-blue-50\/50:hover{background-color:rgba(239,246,255,.5)}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:rgb(0 94 184/var(--tw-text-opacity,1))}.hover\:text-primaryDeep:hover{--tw-text-opacity:1;color:rgb(12 65 136/var(--tw-text-opacity,1))}.hover\:text-slate-700:hover{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(0 94 184/var(--tw-ring-opacity,1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-slate-100:disabled{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.disabled\:text-slate-400:disabled{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:top-24{top:6rem}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mx-8{margin-left:2rem;margin-right:2rem}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:h-12{height:3rem}.sm\:h-\[calc\(100vh-230px\)\]{height:calc(100vh - 230px)}.sm\:min-h-\[360px\]{min-height:360px}.sm\:min-h-\[560px\]{min-height:560px}.sm\:w-64{width:16rem}.sm\:w-auto{width:auto}.sm\:w-fit{width:-moz-fit-content;width:fit-content}.sm\:min-w-\[150px\]{min-width:150px}.sm\:max-w-sm{max-width:24rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-7{padding:1.75rem}.sm\:px-12{padding-left:3rem;padding-right:3rem}.sm\:px-5{padding-left:1.25rem;padding-right:1.25rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:px-8{padding-left:2rem;padding-right:2rem}.sm\:py-6{padding-top:1.5rem;padding-bottom:1.5rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:m-0{margin:0}.md\:flex{display:flex}.md\:hidden{display:none}.md\:min-h-\[76px\]{min-height:76px}.md\:w-auto{width:auto}.md\:grid-cols-\[minmax\(0\2c 1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:flex-nowrap{flex-wrap:nowrap}.md\:items-center{align-items:center}.md\:divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.md\:divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(226 232 240/var(--tw-divide-opacity,1))}.md\:rounded-none{border-radius:0}.md\:border-0{border-width:0}.md\:bg-transparent{background-color:transparent}.md\:px-14{padding-left:3.5rem;padding-right:3.5rem}.md\:px-4{padding-left:1rem;padding-right:1rem}.md\:py-3{padding-top:.75rem;padding-bottom:.75rem}}@media (min-width:1024px){.lg\:col-start-1{grid-column-start:1}.lg\:col-start-2{grid-column-start:2}.lg\:row-start-1{grid-row-start:1}.lg\:row-start-2{grid-row-start:2}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:h-\[460px\]{height:460px}.lg\:h-\[520px\]{height:520px}.lg\:h-\[900px\]{height:900px}.lg\:h-full{height:100%}.lg\:min-h-0{min-height:0}.lg\:min-h-\[520px\]{min-height:520px}.lg\:grid-cols-\[1\.4fr_1fr\]{grid-template-columns:1.4fr 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)_360px\]{grid-template-columns:minmax(0,1fr) 360px}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:overflow-hidden{overflow:hidden}.lg\:overflow-y-auto{overflow-y:auto}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-8{padding-top:2rem;padding-bottom:2rem}.lg\:text-\[40px\]{font-size:40px}} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}body,h1,h2,h3,h4{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.material-symbols-outlined{font-family:Material Symbols Outlined;font-weight:400;font-style:normal;font-size:24px;letter-spacing:normal;text-transform:none;width:1em;height:1em;min-width:1em;flex:0 0 auto;overflow:hidden;word-wrap:normal;direction:ltr;vertical-align:middle;font-variation-settings:"FILL" 0,"wght" 400,"GRAD" 0,"opsz" 24}.material-symbols-outlined,.ui-btn{line-height:1;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap}.ui-btn{box-sizing:border-box;min-height:44px;height:44px;gap:.5rem;border-radius:.375rem;border:1px solid transparent;padding:0 1rem;font-size:.875rem;font-weight:700;transition:background-color .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease}.ui-btn-lg{min-height:52px;height:52px;font-size:.875rem}.ui-btn-sm{min-height:40px;height:40px;padding:0 .75rem}.ui-btn-compact{min-height:34px;height:34px;padding:0 .65rem;gap:.35rem;font-size:.8125rem}.ui-btn-field{min-height:50px;height:50px}.ui-btn-primary{background:#005eb8;color:#fff;box-shadow:0 18px 34px rgba(15,23,42,.06)}.ui-btn-primary:hover{background:#0c4188}.ui-btn-secondary{border-color:#e2e8f0;background:#fff;color:#334155;box-shadow:0 1px 2px rgba(15,23,42,.04)}.ui-btn-secondary:hover{border-color:#005eb8;color:#005eb8}.ui-btn:disabled,.ui-btn[aria-disabled=true]{cursor:not-allowed;box-shadow:none;opacity:.7}.ui-btn[aria-busy=true]{pointer-events:none}.ui-btn-primary:disabled{background:#cbd5e1}.ui-btn .material-symbols-outlined,.ui-btn-icon{width:20px;height:20px;flex:0 0 20px;font-size:20px}.ui-icon-btn{display:inline-flex;width:28px;height:28px;flex:0 0 28px;justify-content:center;transition:background-color .15s ease,color .15s ease}.ui-action-row,.ui-icon-btn{box-sizing:border-box;align-items:center;border-radius:.375rem;line-height:1}.ui-action-row{display:flex;min-height:44px;justify-content:space-between;gap:.75rem;border:1px solid #e2e8f0;padding:0 .75rem;font-size:.875rem;font-weight:700;transition:border-color .15s ease,color .15s ease}.ui-action-row:hover{border-color:#005eb8;color:#005eb8}.spinner{display:inline-flex;width:20px;height:20px;flex:0 0 20px;border-radius:9999px;border:2px solid hsla(0,0%,100%,.35);border-top-color:#fff;animation:spin .75s linear infinite}.ui-btn-secondary .spinner{border-color:#005eb8 rgba(0,94,184,.22) rgba(0,94,184,.22)}.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;-moz-user-select:none;user-select:none}.gradient-board{background:linear-gradient(180deg,#2455a3,#123e7d)}.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;border-left:1px solid;background:#fff;color:#bfdbfe;content:""}.auth-alert.is-error:before{color:#fecaca}.auth-alert.is-visible{pointer-events:auto;opacity:1;transform:translate(-50%)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.left-4{left:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-20{top:5rem}.z-30{z-index:30}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.col-span-2{grid-column:span 2/span 2}.m-3{margin:.75rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mr-1{margin-right:-.25rem}.-mt-1{margin-top:-.25rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-7{margin-bottom:1.75rem}.mb-8{margin-bottom:2rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-4{height:1rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[50px\]{height:50px}.h-\[65svh\]{height:65svh}.h-full{height:100%}.h-px{height:1px}.max-h-\[calc\(100dvh-2rem\)\]{max-height:calc(100dvh - 2rem)}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-\[260px\]{min-height:260px}.min-h-\[360px\]{min-height:360px}.min-h-\[400px\]{min-height:400px}.min-h-\[420px\]{min-height:420px}.min-h-\[620px\]{min-height:620px}.min-h-screen{min-height:100vh}.w-14{width:3.5rem}.w-4{width:1rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[calc\(100vw-2rem\)\]{width:calc(100vw - 2rem)}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-10{min-width:2.5rem}.min-w-\[190px\]{min-width:190px}.min-w-\[260px\]{min-width:260px}.min-w-full{min-width:100%}.max-w-3xl{max-width:48rem}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[360px\]{max-width:360px}.max-w-\[430px\]{max-width:430px}.max-w-\[440px\]{max-width:440px}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-y-0{--tw-translate-y:0px}.translate-y-0,.translate-y-3{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-3{--tw-translate-y:0.75rem}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[1fr_92px_44px\]{grid-template-columns:1fr 92px 44px}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(226 232 240/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-100{--tw-border-opacity:1;border-color:rgb(254 243 199/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-line{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-orange-100{--tw-border-opacity:1;border-color:rgb(255 237 213/var(--tw-border-opacity,1))}.border-outline{--tw-border-opacity:1;border-color:rgb(199 206 216/var(--tw-border-opacity,1))}.border-primary{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[\#eceff3\]{--tw-bg-opacity:1;background-color:rgb(236 239 243/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blueSoft{--tw-bg-opacity:1;background-color:rgb(234 243 255/var(--tw-bg-opacity,1))}.bg-dangerSoft{--tw-bg-opacity:1;background-color:rgb(255 240 240/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-page{--tw-bg-opacity:1;background-color:rgb(243 245 248/var(--tw-bg-opacity,1))}.bg-primary{--tw-bg-opacity:1;background-color:rgb(0 94 184/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-successSoft{--tw-bg-opacity:1;background-color:rgb(233 248 238/var(--tw-bg-opacity,1))}.bg-warnSoft{--tw-bg-opacity:1;background-color:rgb(255 244 232/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-16{padding-left:4rem;padding-right:4rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-7{padding-top:1.75rem;padding-bottom:1.75rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pl-11{padding-left:2.75rem}.pr-12{padding-right:3rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-\[32px\]{font-size:32px}.text-\[44px\]{font-size:44px}.text-\[52px\]{font-size:52px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-\[1\.5\]{line-height:1.5}.tracking-\[\.08em\]{letter-spacing:.08em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-dangerText{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:rgb(0 94 184/var(--tw-text-opacity,1))}.text-primaryDeep{--tw-text-opacity:1;color:rgb(12 65 136/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-successText{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-textMain{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-textSub{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-warnText{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/75{color:hsla(0,0%,100%,.75)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.shadow-\[0_18px_45px_rgba\(15\2c 23\2c 42\2c \.16\)\]{--tw-shadow:0 18px 45px rgba(15,23,42,.16);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color)}.shadow-\[0_18px_45px_rgba\(15\2c 23\2c 42\2c \.16\)\],.shadow-panel{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-panel{--tw-shadow:0 1px 2px rgba(15,23,42,.04);--tw-shadow-colored:0 1px 2px var(--tw-shadow-color)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-red-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 226 226/var(--tw-ring-opacity,1))}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@keyframes spin{to{transform:rotate(1turn)}}@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;border-left:1px solid}.auth-alert.is-visible{transform:translateX(0)}}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.hover\:bg-blue-50\/50:hover{background-color:rgba(239,246,255,.5)}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:rgb(0 94 184/var(--tw-text-opacity,1))}.hover\:text-primaryDeep:hover{--tw-text-opacity:1;color:rgb(12 65 136/var(--tw-text-opacity,1))}.hover\:text-slate-700:hover{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:rgb(0 94 184/var(--tw-border-opacity,1))}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(0 94 184/var(--tw-ring-opacity,1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-slate-100:disabled{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.disabled\:text-slate-400:disabled{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:top-24{top:6rem}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mx-8{margin-left:2rem;margin-right:2rem}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:h-12{height:3rem}.sm\:h-\[calc\(100vh-230px\)\]{height:calc(100vh - 230px)}.sm\:min-h-\[360px\]{min-height:360px}.sm\:min-h-\[560px\]{min-height:560px}.sm\:w-64{width:16rem}.sm\:w-auto{width:auto}.sm\:w-fit{width:-moz-fit-content;width:fit-content}.sm\:min-w-\[150px\]{min-width:150px}.sm\:max-w-sm{max-width:24rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-7{padding:1.75rem}.sm\:px-12{padding-left:3rem;padding-right:3rem}.sm\:px-5{padding-left:1.25rem;padding-right:1.25rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:px-8{padding-left:2rem;padding-right:2rem}.sm\:py-6{padding-top:1.5rem;padding-bottom:1.5rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:m-0{margin:0}.md\:flex{display:flex}.md\:hidden{display:none}.md\:min-h-\[76px\]{min-height:76px}.md\:w-auto{width:auto}.md\:grid-cols-\[minmax\(0\2c 1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:flex-nowrap{flex-wrap:nowrap}.md\:items-center{align-items:center}.md\:divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.md\:divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(226 232 240/var(--tw-divide-opacity,1))}.md\:rounded-none{border-radius:0}.md\:border-0{border-width:0}.md\:bg-transparent{background-color:transparent}.md\:px-14{padding-left:3.5rem;padding-right:3.5rem}.md\:px-4{padding-left:1rem;padding-right:1rem}.md\:py-3{padding-top:.75rem;padding-bottom:.75rem}}@media (min-width:1024px){.lg\:col-start-1{grid-column-start:1}.lg\:col-start-2{grid-column-start:2}.lg\:row-start-1{grid-row-start:1}.lg\:row-start-2{grid-row-start:2}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:h-\[460px\]{height:460px}.lg\:h-\[520px\]{height:520px}.lg\:h-\[900px\]{height:900px}.lg\:h-full{height:100%}.lg\:min-h-0{min-height:0}.lg\:min-h-\[520px\]{min-height:520px}.lg\:grid-cols-\[1\.4fr_1fr\]{grid-template-columns:1.4fr 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)_360px\]{grid-template-columns:minmax(0,1fr) 360px}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:overflow-hidden{overflow:hidden}.lg\:overflow-y-auto{overflow-y:auto}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-8{padding-top:2rem;padding-bottom:2rem}.lg\:text-\[40px\]{font-size:40px}} \ No newline at end of file diff --git a/templates/account_security.html b/templates/account_security.html index 6976147..d50651a 100644 --- a/templates/account_security.html +++ b/templates/account_security.html @@ -35,7 +35,7 @@ {% else %}
- +
{% endif %} @@ -49,11 +49,12 @@

需验证当前密码、原邮箱和新邮箱。完成后将退出所有设备,并用新邮箱登录。

-
+ - - + + +
@@ -69,10 +70,49 @@
- +
+ {% endblock %} diff --git a/templates/login.html b/templates/login.html index 821b7d2..03ee2b4 100644 --- a/templates/login.html +++ b/templates/login.html @@ -14,6 +14,7 @@
- +
verified_user - +
-
{{ captcha }}
- +
{{ captcha }}
+
refresh
@@ -143,14 +144,14 @@
- +
verified_user - +
-
{{ captcha }}
- +
{{ captcha }}
+
refresh
@@ -277,21 +278,19 @@ form.querySelectorAll('.auth-input-error').forEach(clearFieldError); } - function fieldForServerMessage(message) { + function fieldForServerMessage() { const activeForm = document.querySelector('[data-auth-form]'); if (!activeForm) return null; - if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]'); - if (message.includes('密码')) return activeForm.querySelector('input[name="password"]'); - if (message.includes('邮箱')) return activeForm.querySelector('input[name="email"]'); - if (message.includes('用户名') || message.includes('显示名')) return activeForm.querySelector('input[name="username"]'); - return null; + return window.__authErrorField + ? activeForm.querySelector(`[name="${window.__authErrorField}"]`) + : null; } const flashedMessages = window.__flashMessages || []; const pageNotice = window.__pageNotice; if (flashedMessages.length) { const [category, message] = flashedMessages[flashedMessages.length - 1]; - const serverField = category === 'error' ? fieldForServerMessage(message) : null; + const serverField = category === 'error' ? fieldForServerMessage() : null; showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField); if (category === 'error') { markFieldError(serverField); diff --git a/templates/verification.html b/templates/verification.html index 944e04e..7b55f63 100644 --- a/templates/verification.html +++ b/templates/verification.html @@ -13,11 +13,11 @@
mark_email_read

{{ title }}

-

验证码已发送至 {{ email }},10 分钟内有效。

+

邮箱验证码已发送至 {{ email }},10 分钟内有效。

{% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %} -

{{ message }}

+

{{ message }}

{% endfor %} {% endwith %} @@ -25,7 +25,7 @@
- 请输入 6 位验证码 + 请输入 6 位邮箱验证码
{% for index in range(6) %}
+ {% if purpose == 'login' %} + + {% endif %} diff --git a/tests/test_auth_registration.py b/tests/test_auth_registration.py index e9c9f45..c9fa61d 100644 --- a/tests/test_auth_registration.py +++ b/tests/test_auth_registration.py @@ -10,6 +10,7 @@ from flask import session from app import create_app from app.config import Config +from app.email import EmailDeliveryError from app.extensions import db from app.models import EmailVerificationCode, TrustedDevice, User from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password @@ -60,6 +61,28 @@ class EmailAuthenticationTest(unittest.TestCase): ).isoformat() 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): class TestConfig(Config): TESTING = True @@ -143,7 +166,118 @@ class EmailAuthenticationTest(unittest.TestCase): verify = client.get("/verify/register") response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"}) 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) + + @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.secrets.randbelow", return_value=123456) @@ -154,10 +288,10 @@ 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() 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), "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") 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) with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1) @@ -190,7 +324,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() page = client.get("/login") 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): 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"})