feat(auth): 手机号也能用密码登录,验证码之外多一条路

短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
This commit is contained in:
Steven
2026-10-31 04:04:55 +08:00
parent 29aae12224
commit a5510cdb79
9 changed files with 544 additions and 56 deletions
+34
View File
@@ -29,6 +29,10 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -91,9 +95,39 @@
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
### `POST /auth/login/password`
```json
{ "phone": "13800000000", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
### `POST /auth/password`
```json
{ "password": "dadao2026", "old_password": "old-one-1" }
```
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
### `PATCH /auth/me`
```json
{ "nickname": "大道" }
```
返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
---
@@ -0,0 +1,40 @@
"""登录密码(M1 账号功能)
Revision ID: 0008
Revises: 0007
Create Date: 2026-10-31
M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、
或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。
两个取舍:
1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程;
登录页两种方式并存,谁设过谁自己知道。
2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。
密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0008"
down_revision: str = "0007"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE app_user
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
"""
)
def downgrade() -> None:
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash")
+132 -31
View File
@@ -1,4 +1,8 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
"""认证:手机号 + 验证码,可选登录密码。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
"""
from __future__ import annotations
@@ -14,7 +18,13 @@ from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace
from ..schemas import ok
from ..security import create_token_pair, decode_token
from ..security import (
create_token_pair,
decode_token,
hash_password,
password_problem,
verify_password,
)
from ..stores import get_store, sms_code_key, sms_throttle_key
router = APIRouter(prefix="/auth", tags=["认证"])
@@ -22,11 +32,36 @@ settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"created_at": user.created_at,
}
def _tokens_out(tokens) -> dict:
return {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
@@ -36,6 +71,22 @@ class LoginIn(BaseModel):
code: str = Field(min_length=4, max_length=8)
class PasswordLoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
password: str = Field(min_length=1, max_length=64)
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
class PasswordIn(BaseModel):
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
password: str = Field(min_length=1, max_length=64)
old_password: str | None = Field(default=None, max_length=64)
class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@@ -81,40 +132,54 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
await session.flush()
tokens = create_token_pair(user.id)
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"created_at": user.created_at,
},
"tokens": {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
},
}
)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/login/password", summary="密码登录")
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"""手机号 + 密码登录。
两个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
"""
store = get_store()
fail_key = f"pwd_fail:{body.phone}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
)
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对")
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
await store.delete(fail_key)
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict:
user_id = decode_token(body.refresh_token, "refresh")
tokens = create_token_pair(user_id)
return ok(
{
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
)
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
return ok(_tokens_out(tokens))
@router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
rows = (
await session.execute(
select(Workspace, Member.role)
@@ -126,11 +191,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
},
"user": _user_out(row),
"workspaces": [
{
"id": str(ws.id),
@@ -144,3 +205,43 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
],
}
)
@router.patch("/me", summary="改自己的昵称")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if body.nickname is not None:
nickname = body.nickname.strip()
if not nickname:
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
await session.flush()
return ok({"user": _user_out(row)})
@router.post("/password", summary="设置或修改登录密码")
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if row.password_hash:
if not body.old_password:
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
if not verify_password(body.old_password, row.password_hash):
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
if hmac.compare_digest(body.old_password, body.password):
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
row.password_hash = hash_password(body.password)
await session.flush()
return ok({"has_password": True})
+8
View File
@@ -25,6 +25,10 @@ class ErrorCode(IntEnum):
SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012
PASSWORD_INVALID = 2013
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -86,6 +90,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403,
ErrorCode.PASSWORD_INVALID: 400,
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
+2
View File
@@ -45,6 +45,8 @@ class AppUser(Base, Timestamps):
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
class Workspace(Base, Timestamps):
+63
View File
@@ -8,6 +8,8 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import time
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
def invite_code_matches(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.strip().upper(), expected)
# —— 登录密码 ——
# 用标准库 scrypt,不引入 passlib/bcrypt:少一个依赖,也就少一处装不上的风险。
# 存储格式 `scrypt$n$r$p$salt_b64$hash_b64`,参数写进串里,将来调强度不用改代码。
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_PASSWORD_MIN = 8
_PASSWORD_MAX = 64
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
)
return "$".join(
[
"scrypt",
str(_SCRYPT_N),
str(_SCRYPT_R),
str(_SCRYPT_P),
base64.b64encode(salt).decode(),
base64.b64encode(digest).decode(),
]
)
def verify_password(password: str, stored: str | None) -> bool:
"""stored 为空(没设过密码)一律返回 False,别把"没密码"当成"密码对"。"""
if not stored:
return False
try:
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode("utf-8"),
salt=base64.b64decode(salt_b64),
n=int(n),
r=int(r),
p=int(p),
dklen=len(base64.b64decode(hash_b64)),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(digest, base64.b64decode(hash_b64))
def password_problem(password: str) -> str | None:
"""返回不合格的原因;合格返回 None。规则写在一处,设置与修改都用它。"""
if len(password) < _PASSWORD_MIN:
return f"密码至少 {_PASSWORD_MIN} 位"
if len(password) > _PASSWORD_MAX:
return f"密码最多 {_PASSWORD_MAX} 位"
has_letter = any(c.isalpha() for c in password)
has_digit = any(c.isdigit() for c in password)
if not (has_letter and has_digit):
return "密码要同时有字母和数字"
return None
+187
View File
@@ -0,0 +1,187 @@
"""账号功能:昵称、密码设置/修改、密码登录。
这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看:
- 密码一字节都不回给客户端(只回 has_password)
- 没设过密码的人不会被密码登录误放进去
- 改完密码,旧密码立刻失效
- 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜
"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import auth, login, unwrap
async def _me(client: AsyncClient, user: dict) -> dict:
return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None:
user = await login(client)
data = await _me(client, user)
assert data["user"]["has_password"] is False
assert data["user"]["phone"].endswith(user["phone"][-4:])
assert "****" in data["user"]["phone"]
# 只回「设没设」,不回密码本身:字段白名单比黑名单可靠
assert set(data["user"]) == {
"id",
"phone",
"nickname",
"avatar_url",
"has_password",
"created_at",
}
async def test_patch_nickname(client: AsyncClient) -> None:
user = await login(client)
data = unwrap(
await client.patch(
"/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"]
)
)
assert data["user"]["nickname"] == "老王"
# 空昵称要被拦住,不能变成"无名氏"
resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"])
assert resp.json()["code"] == 1002
async def test_set_password_then_login_with_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2026"},
headers=user["headers"],
)
)
assert (await _me(client, user))["user"]["has_password"] is True
# 验证码登录照旧可用,两条路并存
data = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "dadao2026"},
)
)
assert data["tokens"]["access_token"]
assert data["user"]["id"] == user["user_id"]
async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"}
)
assert resp.status_code == 400
assert resp.json()["code"] == 2013
async def test_password_login_on_account_without_password(client: AsyncClient) -> None:
user = await login(client)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去
assert resp.json()["code"] == 2013
async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
# 不给旧密码 → 拒绝
resp = await client.post(
"/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"]
)
assert resp.json()["code"] == 2013
# 旧密码给错 → 拒绝
resp = await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "nope-1234"},
headers=user["headers"],
)
assert resp.json()["code"] == 2013
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "dadao2026"},
headers=user["headers"],
)
)
# 新密码能登,旧密码不能
unwrap(
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"}
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_weak_password_rejected(client: AsyncClient) -> None:
user = await login(client)
for weak in ["short1", "12345678", "abcdefgh"]:
resp = await client.post(
"/api/v1/auth/password", json={"password": weak}, headers=user["headers"]
)
assert resp.json()["code"] == 2015, weak
async def test_password_endpoints_require_login(client: AsyncClient) -> None:
resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"})
assert resp.status_code == 401
resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope"))
assert resp.status_code == 401
async def test_repeated_failures_are_throttled(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
for _ in range(10):
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"}
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求
assert resp.json()["code"] == 2016
+3
View File
@@ -2,6 +2,9 @@ export interface User {
id: string;
phone: string;
nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
created_at?: string;
}
+75 -25
View File
@@ -37,6 +37,8 @@ export function LoginPage() {
const next = safeNext(params.get("next"));
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("code");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -69,7 +71,13 @@ export function LoginPage() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code });
const data =
mode === "code"
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
phone,
password,
});
session.save(data.tokens);
await reload();
navigate(next ?? "/", { replace: true });
@@ -135,9 +143,36 @@ export function LoginPage() {
</div>
<h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p>
<p className="mt-1.5 text-sm text-ink-500">
{mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
</p>
<form className="mt-7 space-y-4" onSubmit={submit}>
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
{(
[
["code", "验证码"],
["password", "密码"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => {
setMode(key);
setError(null);
}}
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
}`}
>
{label}
</button>
))}
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="手机号">
<input
id="phone"
@@ -151,29 +186,44 @@ export function LoginPage() {
/>
</Field>
<Field label="验证码">
<div className="flex gap-2">
{mode === "code" ? (
<Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
) : (
<Field label="密码">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
id="password"
type="password"
className="input h-11"
autoComplete="current-password"
placeholder="你的登录密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={64}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
</Field>
)}
{devCode && (
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
@@ -188,7 +238,7 @@ export function LoginPage() {
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={busy || !phoneValid || code.length < 4}
disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
>
{busy ? "处理中…" : "登录"}
</Button>