feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s

短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
This commit is contained in:
Steven
2026-11-03 01:04:26 +08:00
parent c0c5230fd1
commit b5170ad1ce
20 changed files with 955 additions and 16 deletions
+41 -5
View File
@@ -37,6 +37,8 @@
| 2018 | 422 | 邮箱格式不对 | 就地提示 | | 2018 | 422 | 邮箱格式不对 | 就地提示 |
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 | | 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 | | 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 |
| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 |
| 3001 | 404 | 工作区不存在 | — | | 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -69,19 +71,53 @@
## 1. 认证 `/auth` ## 1. 认证 `/auth`
登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染, 登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端,
免得用户点进一条关着的路: 前端据此渲染,免得用户点进一条关着的路:
### `GET /auth/methods` ### `GET /auth/methods`
```json ```json
{ "password": true, "email_password": true, "sms_code": false } { "password": true, "email_password": true, "sms_code": false, "email_register": true }
``` ```
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017` `SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号", 「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。 这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
### `POST /auth/email/code`
```json
{ "email": "someone@example.com" }
```
发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。
邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道);
60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去);
`EMAIL_REGISTER_ENABLED=false` → `2017`。
响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**;
只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。
### `POST /auth/register`
```json
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
```
```json
{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … },
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
```
注册成功即登录态(直接返回 `tokens`)。规则:
- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放
- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017`
- 昵称不填就取邮箱 `@` 前面那段
- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串
邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。
### `POST /auth/sms/send` ### `POST /auth/sms/send`
```json ```json
@@ -153,8 +189,8 @@
### `GET /auth/me` ### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、`platform_admin` `user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、
(能不能进「系统设置」)。 `email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。
--- ---
+45
View File
@@ -603,3 +603,48 @@
自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**—— 自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**——
要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。 要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。
- 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。 - 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。
## 10. 邮箱验证码注册(2026-11-03)
### 10.1 需求
手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;"
### 10.2 决定
- **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。
理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`2019`)。
- **邮箱既是账号名也是唯一联系方式**:注册必须真验一次码,"随手填一个"会让账号变成没人能找回的孤岛。
验证通过时间落 `app_user.email_verified_at`,`/auth/me` 回 `email_verified`。
- **`app_user.phone` 改为可空**(迁移 0011):邮箱注册的账号本来就没有手机号。
老账号不受影响;`user.phone` 对这类账号回空串而不是星号串(成员列表同理)。
- **防刷**:同邮箱 60 秒内只能发一次(`2022`);验证码 10 分钟有效;错 5 次即作废(`2021`)——
6 位数可以被刮,不设上限等于把注册口子交给脚本。
- **本机联调开关** `EMAIL_CODE_DEV_ECHO`:为 true 时把码直接回给调用方且**不发真信**,
否则每跑一次 e2e 就往真实收件箱塞一封。生产打开会让 `validate_runtime()` 拒绝启动。
### 10.3 后端
| 位置 | 内容 |
|---|---|
| `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) |
| `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) |
| `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` |
| `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) |
| `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` |
| `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 |
| `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` |
### 10.4 Web
- `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀);
注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。
- `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。
- `App.tsx`:新增 `/register` 路由。
### 10.5 验证
- `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、
注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。
- e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 →
同一个邮箱再发码回 `2019`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -0,0 +1,41 @@
"""app_user 支持邮箱注册:放开 phone 非空、记录邮箱已验证时间
手机验证码登录关掉以后,新用户是「邮箱 + 验证码」注册进来的,此时还没有手机号;
`app_user.phone` 原来 NOT NULL,不放开就没法插这类账号。
`email_verified_at` 用来区分"填了个邮箱字符串"和"这个地址确实验证过":
注册流程发码成功后写入;账号页手工绑邮箱时同一套流程。
Revision ID: 0011
Revises: 0010
"""
from __future__ import annotations
from alembic import op
from app.roles import APP_ROLE
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们
op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL")
op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
op.execute(
"COMMENT ON COLUMN app_user.email_verified_at IS "
"'邮箱验证通过的时间;为空表示还没验证过这个地址'"
)
# 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列
op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}")
def downgrade() -> None:
# 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败
op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL")
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at")
op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL")
+144 -4
View File
@@ -1,9 +1,12 @@
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。 """认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码 当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接 等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示 回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。 服务端真开着的登录方式,免得点进去才报错。
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,10 +14,12 @@ from __future__ import annotations
import hmac import hmac
import re import re
import secrets import secrets
from datetime import datetime, timezone
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings from ..config import get_settings
from ..deps import CurrentUser, SessionDep from ..deps import CurrentUser, SessionDep
@@ -28,7 +33,15 @@ from ..security import (
password_problem, password_problem,
verify_password, verify_password,
) )
from ..stores import get_store, sms_code_key, sms_throttle_key from ..services import mail
from ..stores import (
email_code_key,
email_throttle_key,
email_tries_key,
get_store,
sms_code_key,
sms_throttle_key,
)
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings() settings = get_settings()
@@ -42,7 +55,9 @@ PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900 PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str: def mask_phone(phone: str | None) -> str:
if not phone:
return ""
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
@@ -60,10 +75,11 @@ def _user_out(user: AppUser) -> dict:
"id": str(user.id), "id": str(user.id),
"phone": mask_phone(user.phone), "phone": mask_phone(user.phone),
"email": user.email, "email": user.email,
"email_verified": user.email_verified_at is not None,
"nickname": user.nickname, "nickname": user.nickname,
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"has_password": bool(user.password_hash), "has_password": bool(user.password_hash),
"platform_admin": user.phone in settings.platform_admin_phones, "platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
"created_at": user.created_at, "created_at": user.created_at,
} }
@@ -125,6 +141,7 @@ async def methods() -> dict:
"password": True, "password": True,
"email_password": True, "email_password": True,
"sms_code": settings.sms_login_enabled, "sms_code": settings.sms_login_enabled,
"email_register": settings.email_register_enabled,
} }
) )
@@ -321,3 +338,126 @@ async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep)
row.password_hash = hash_password(body.password) row.password_hash = hash_password(body.password)
await session.flush() await session.flush()
return ok({"has_password": True}) return ok({"has_password": True})
# ── 邮箱注册 ────────────────────────────────────────────
class EmailCodeIn(BaseModel):
email: str = Field(max_length=255, examples=["someone@example.com"])
class RegisterIn(BaseModel):
"""邮箱 + 验证码 + 密码,一步把账号建出来。
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
"""
email: str = Field(max_length=255)
code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=1, max_length=64)
nickname: str | None = Field(default=None, max_length=32)
async def _email_taken(session: AsyncSession, email: str) -> bool:
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
return (await session.execute(stmt)).first() is not None
@router.post("/email/code", summary="发送邮箱注册验证码")
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
store = get_store()
if settings.email_code_throttle_seconds > 0:
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
if hits > 1:
raise AppError(
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
)
code = f"{secrets.randbelow(1_000_000):06d}"
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
await store.delete(email_tries_key(email))
minutes = max(1, settings.email_code_ttl_seconds // 60)
if settings.email_code_dev_echo:
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
return ok(
{
"sent": True,
"expires_in": settings.email_code_ttl_seconds,
"dev_code": code,
}
)
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
async def register(body: RegisterIn, session: SessionDep) -> dict:
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
真验一次码,而不是"填个邮箱就算"。
"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
store = get_store()
stored = await store.get(email_code_key(email))
if not stored:
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
if tries > settings.email_code_max_attempts:
await store.delete(email_code_key(email))
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
if not hmac.compare_digest(stored, body.code.strip()):
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
user = AppUser(
email=email,
phone=None,
nickname=nickname,
password_hash=hash_password(body.password),
email_verified_at=datetime.now(timezone.utc),
status="active",
)
session.add(user)
await session.flush()
await store.delete(email_code_key(email))
await store.delete(email_tries_key(email))
await store.delete(email_throttle_key(email))
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
+2 -1
View File
@@ -132,7 +132,8 @@ async def list_members(
"role": m.role, "role": m.role,
"status": m.status, "status": m.status,
"nickname": u.nickname, "nickname": u.nickname,
"phone": f"{u.phone[:3]}****{u.phone[-4:]}", # 邮箱注册的账号没有手机号:留空比显示一串星号诚实
"phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "",
"joined_at": m.joined_at, "joined_at": m.joined_at,
} }
for m, u in rows for m, u in rows
+19
View File
@@ -85,6 +85,20 @@ class Settings(BaseSettings):
smtp_use_ssl: bool = True smtp_use_ssl: bool = True
smtp_timeout_seconds: int = 10 smtp_timeout_seconds: int = 10
# ── 邮箱注册 ──────────────────────────────────────────
# 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。
# 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定:
# 没配 SMTP 时发码接口会回 2020,而不是假装发出去。
email_register_enabled: bool = True
email_code_ttl_seconds: int = 600
# 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流
email_code_throttle_seconds: int = 60
# 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮)
email_code_max_attempts: int = 5
# 开发/联调用:把验证码直接回给调用方,**不发真信**。
# 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。
email_code_dev_echo: bool = False
# ── 幂等 ────────────────────────────────────────────── # ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True idempotency_enabled: bool = True
idempotency_ttl_seconds: int = 86_400 idempotency_ttl_seconds: int = 86_400
@@ -163,6 +177,11 @@ class Settings(BaseSettings):
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码" "短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉" "(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
) )
if self.email_code_dev_echo:
problems.append(
"EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方,"
"等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)"
)
if problems: if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems)) raise RuntimeError("配置校验失败:" + ";".join(problems))
+3 -1
View File
@@ -27,7 +27,9 @@ ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
@dataclass(frozen=True) @dataclass(frozen=True)
class AuthUser: class AuthUser:
id: uuid.UUID id: uuid.UUID
phone: str # 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按
# "空就不匹配" 处理,别拿 None 去比字符串。
phone: str | None
nickname: str nickname: str
+4
View File
@@ -33,6 +33,8 @@ class ErrorCode(IntEnum):
EMAIL_INVALID = 2018 EMAIL_INVALID = 2018
EMAIL_TAKEN = 2019 EMAIL_TAKEN = 2019
MAIL_SEND_FAILED = 2020 MAIL_SEND_FAILED = 2020
EMAIL_CODE_INVALID = 2021
EMAIL_CODE_TOO_FREQUENT = 2022
# 3xxx 租户 / 权限 # 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001 WORKSPACE_NOT_FOUND = 3001
@@ -102,6 +104,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.EMAIL_INVALID: 422, ErrorCode.EMAIL_INVALID: 422,
ErrorCode.EMAIL_TAKEN: 409, ErrorCode.EMAIL_TAKEN: 409,
ErrorCode.MAIL_SEND_FAILED: 502, ErrorCode.MAIL_SEND_FAILED: 502,
ErrorCode.EMAIL_CODE_INVALID: 400,
ErrorCode.EMAIL_CODE_TOO_FREQUENT: 429,
ErrorCode.WORKSPACE_NOT_FOUND: 404, ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403, ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403, ErrorCode.INSUFFICIENT_ROLE: 403,
+6 -1
View File
@@ -41,7 +41,9 @@ class AppUser(Base, Timestamps):
__tablename__ = "app_user" __tablename__ = "app_user"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) # 手机号可空:短信登录关掉以后,新账号是「邮箱 + 验证码」注册的,没有手机号。
# 老账号照旧有;将来绑手机号(换绑/找回)再补。
phone: Mapped[str | None] = mapped_column(String(32), unique=True)
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512)) avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
@@ -50,6 +52,9 @@ class AppUser(Base, Timestamps):
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。 # 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。 # 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255)) email: Mapped[str | None] = mapped_column(String(255))
# 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。
# 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SystemSetting(Base, Timestamps): class SystemSetting(Base, Timestamps):
+21
View File
@@ -200,3 +200,24 @@ async def config_payload(session: AsyncSession) -> dict:
"source": "db" if row else "env", "source": "db" if row else "env",
"updated_at": row.updated_at if row else None, "updated_at": row.updated_at if row else None,
} }
async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None:
"""发注册/验证用的数字验证码。
模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条
没头没尾的六位数,用户只会当成钓鱼。
"""
text = (
f"你的达道Task验证码是 {code}\n\n"
f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。"
)
html = (
'<div style="font:14px/1.7 -apple-system,Segoe UI,sans-serif;color:#1f2a28">'
"<p>你的达道Task验证码是:</p>"
f'<p style="font:600 28px/1.4 ui-monospace,Menlo,Consolas,monospace;'
f'letter-spacing:6px;color:#2f6054">{code}</p>'
f"<p>{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。</p>"
"</div>"
)
await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html)
+14
View File
@@ -107,5 +107,19 @@ def sms_throttle_key(phone: str) -> str:
return f"sms:throttle:{phone}" return f"sms:throttle:{phone}"
# ── 邮箱注册 ────────────────────────────────────────────
def email_code_key(email: str) -> str:
return f"email:code:{email}"
def email_tries_key(email: str) -> str:
"""试错计数:错一次算一次,超过上限就把验证码作废,别让人拿 6 位数当彩票刮。"""
return f"email:tries:{email}"
def email_throttle_key(email: str) -> str:
return f"email:throttle:{email}"
def idempotency_key(user_id: str, key: str) -> str: def idempotency_key(user_id: str, key: str) -> str:
return f"idem:{user_id}:{key}" return f"idem:{user_id}:{key}"
+1
View File
@@ -30,6 +30,7 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient)
"id", "id",
"phone", "phone",
"email", "email",
"email_verified",
"nickname", "nickname",
"avatar_url", "avatar_url",
"has_password", "has_password",
+3 -1
View File
@@ -23,7 +23,9 @@ def _prod(**overrides: object) -> Settings:
"sms_provider": "aliyun", "sms_provider": "aliyun",
} }
values.update(overrides) values.update(overrides)
return Settings(**values) # _env_file=None:本机 backend/.env 里有 EMAIL_CODE_DEV_ECHO=true(跑 e2e 用的),
# 那条在生产是硬错误;这里要验的是 prod 自己的规则,不能被开发机的 .env 带偏。
return Settings(_env_file=None, **values)
def test_prod_rejects_dev_sms_when_login_enabled() -> None: def test_prod_rejects_dev_sms_when_login_enabled() -> None:
+217
View File
@@ -0,0 +1,217 @@
"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。
手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通:
① 发码真的走到 SMTP(不是"调了函数就算");② 验证码错了/过期了要报错;
③ 建出来的账号没有手机号也能正常用(`app_user.phone` 因此可空)。
本机联调用的 `EMAIL_CODE_DEV_ECHO` 只在 dev 生效,生产打开会被 validate_runtime 拒掉。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from app.api import auth as auth_api
from app.errors import ErrorCode
from app.services import mail
from tests.conftest import unwrap
def _settings() -> Any:
"""settings 是模块级的缓存对象,monkeypatch 改它、用例结束自动还原。"""
return auth_api.settings
@pytest.fixture
async def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""把真发信换成记账:记下收件人与验证码,不碰网络。"""
box: list[dict[str, Any]] = []
async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None:
box.append({"to": to, "code": code, "minutes": minutes})
monkeypatch.setattr(_settings(), "email_code_dev_echo", False)
monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0)
monkeypatch.setattr(mail, "send_verify_code", fake_send)
return box
async def _send(client: AsyncClient, email: str) -> dict[str, Any]:
return unwrap(await client.post("/api/v1/auth/email/code", json={"email": email}))
async def test_send_code_goes_through_smtp(client: AsyncClient, sent: list[dict[str, Any]]) -> None:
"""发码必须真的交给发信服务,并且邮箱统一小写。"""
data = await _send(client, " Newcomer@Example.COM ")
assert data["sent"] is True
assert data["expires_in"] > 0
# 不带 dev_code:真发信时把码回给客户端等于白验
assert "dev_code" not in data
assert sent == [{"to": "newcomer@example.com", "code": sent[0]["code"], "minutes": 10}]
assert len(sent[0]["code"]) == 6 and sent[0]["code"].isdigit()
async def test_dev_echo_returns_code_without_sending(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""本机联调:不回真信,把码回给调用方(生产会拒绝启动)。"""
called: list[str] = []
async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None:
called.append(to)
monkeypatch.setattr(_settings(), "email_code_dev_echo", True)
monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0)
monkeypatch.setattr(mail, "send_verify_code", fake_send)
data = await _send(client, "dev@example.com")
assert data["sent"] is True
assert len(data["dev_code"]) == 6
assert called == []
async def test_register_creates_account_and_logs_in(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
"""注册完就是登录态:能拿到令牌,/auth/me 也认。"""
email = "fresh@example.com"
await _send(client, email)
code = sent[-1]["code"]
data = unwrap(
await client.post(
"/api/v1/auth/register",
json={"email": " FRESH@example.com ", "code": code, "password": "dadao2026"},
)
)
assert data["user"]["email"] == email
assert data["user"]["email_verified"] is True
assert data["user"]["has_password"] is True
# 邮箱注册没有手机号:脱敏字段留空,别拼出一串星号
assert data["user"]["phone"] == ""
# 没填昵称就取 @ 前面那段,别让新账号顶着一个空名字
assert data["user"]["nickname"] == "fresh"
headers = {"Authorization": f"Bearer {data['tokens']['access_token']}"}
me = unwrap(await client.get("/api/v1/auth/me", headers=headers))
assert me["user"]["id"] == data["user"]["id"]
# 验证码用过就作废,不能拿同一个码再注册一次
replay = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": code, "password": "dadao2026"},
)
assert replay.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
# 注册完可以用「邮箱 + 密码」登录
login = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"email": email, "password": "dadao2026"},
)
)
assert login["user"]["id"] == data["user"]["id"]
async def test_register_rejects_wrong_or_missing_code(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
email = "oops@example.com"
await _send(client, email)
wrong = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": "000000", "password": "dadao2026"},
)
assert wrong.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
# 压根没发过码:同样"过期",不告诉对方"这个邮箱存不存在"
never = await client.post(
"/api/v1/auth/register",
json={"email": "never@example.com", "code": "123456", "password": "dadao2026"},
)
assert never.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
async def test_register_code_burns_out_after_too_many_tries(
client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
"""6 位数可以被刮,所以错够次数就把码作废。"""
monkeypatch.setattr(_settings(), "email_code_max_attempts", 2)
email = "guess@example.com"
await _send(client, email)
good = sent[-1]["code"]
wrong = "000000" if good != "000000" else "111111"
for _ in range(2):
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": wrong, "password": "dadao2026"},
)
assert resp.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
burned = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": good, "password": "dadao2026"},
)
assert burned.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
assert "重新获取" in burned.json()["message"]
async def test_send_code_refuses_registered_email(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
email = "taken@example.com"
await _send(client, email)
unwrap(
await client.post(
"/api/v1/auth/register",
json={"email": email, "code": sent[-1]["code"], "password": "dadao2026"},
)
)
again = await client.post("/api/v1/auth/email/code", json={"email": email})
assert again.json()["code"] == ErrorCode.EMAIL_TAKEN
async def test_send_code_throttles(client: AsyncClient, sent: list[dict[str, Any]]) -> None:
_settings().email_code_throttle_seconds = 60
try:
await _send(client, "throttle@example.com")
again = await client.post("/api/v1/auth/email/code", json={"email": "throttle@example.com"})
assert again.json()["code"] == ErrorCode.EMAIL_CODE_TOO_FREQUENT
finally:
_settings().email_code_throttle_seconds = 0
async def test_register_validates_password_and_email(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
await _send(client, "weak@example.com")
weak = await client.post(
"/api/v1/auth/register",
json={"email": "weak@example.com", "code": sent[-1]["code"], "password": "short"},
)
assert weak.json()["code"] == ErrorCode.PASSWORD_WEAK
bad = await client.post("/api/v1/auth/email/code", json={"email": "not-an-email"})
assert bad.json()["code"] == ErrorCode.EMAIL_INVALID
async def test_registration_can_be_closed(
client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(_settings(), "email_register_enabled", False)
assert (await client.post("/api/v1/auth/email/code", json={"email": "x@example.com"})).json()[
"code"
] == ErrorCode.LOGIN_METHOD_DISABLED
assert (
await client.post(
"/api/v1/auth/register",
json={"email": "x@example.com", "code": "123456", "password": "dadao2026"},
)
).json()["code"] == ErrorCode.LOGIN_METHOD_DISABLED
# /auth/methods 也要如实告诉前端这条路关着
methods = unwrap(await client.get("/api/v1/auth/methods"))
assert methods["email_register"] is False
+98
View File
@@ -0,0 +1,98 @@
/**
* 注册的验收:从登录页走到注册页,把账号建出来,再用它登回来。
*
* 这里验的是"一个人真能自己进来",所以每一步都从界面走:
* ① 登录页有「用邮箱注册」(服务端开着才会出现)
* ② 注册页拿到验证码(本机 EMAIL_CODE_DEV_ECHO 会把码回给页面,不发真信)
* ③ 提交后直接进首页,说明注册即登录
* ④ 退出、用「邮箱 + 密码」再登一次,证明邮箱真的是账号名
* ⑤ 同一个邮箱再发码,必须被挡(2019),而不是又建一个号
*/
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
}
}
const email = `reg${Date.now()}@example.com`;
const password = `dadao${Math.floor(Math.random() * 900000 + 1000)}`;
const browser = await chromium.launch({ executablePath: CHROME });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
try {
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
const registerLink = page.getByRole("link", { name: "用邮箱注册" });
check("登录页有「用邮箱注册」入口", (await registerLink.count()) > 0);
await registerLink.click();
await page.waitForURL("**/register");
await page.waitForSelector("#email");
check("能进注册页", true);
await page.locator("#email").fill(email);
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
const notice = await page.locator("text=开发环境验证码").first().innerText();
const code = notice.match(/\d{6}/)?.[0] ?? "";
check("本机拿得到验证码(生产只发到邮箱)", code.length === 6, notice);
await page.locator("#code").fill(code);
await page.locator("#password").fill(password);
await page.locator("#confirm").fill(password);
await page.locator("#nickname").fill("注册验收");
await page.getByRole("button", { name: "注册并登录" }).click();
const landed = await page
.waitForURL(`${WEB}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
check("注册完直接进首页(注册即登录)", landed, page.url());
// 退出后用「邮箱 + 密码」登回来:邮箱真是账号名
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL("**/login");
await page.getByRole("tab", { name: "邮箱" }).click();
await page.locator("#account").fill(email);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
const back = await page
.waitForURL(`${WEB}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
check("邮箱 + 密码能登回来", back);
// 同一个邮箱再发码:必须拦住,别再建一个号
const again = await page.request.post(`${API}/auth/email/code`, { data: { email } });
const body = await again.json();
check("已注册的邮箱再发码回 2019", body.code === 2019, JSON.stringify(body));
check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | "));
} catch (err) {
fail += 1;
console.log(`FAIL 脚本异常 — ${err}`);
} finally {
await browser.close();
}
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
+32
View File
@@ -0,0 +1,32 @@
/**
* 拍注册页的截图(文档用)。需要前端 5180、后端 8100 起着。
*
* 用本机 dev 的 EMAIL_CODE_DEV_ECHO:把码回给页面,截图里能看到"开发环境验证码"那一行,
* 正好说明生产只会发到邮箱。真提交一次再拍,页面右侧是"下一步会怎样"的说明,不必留成功态。
*/
import { chromium } from "playwright-core";
import { mkdirSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const OUT = "e2e/artifacts";
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
await page.goto(`${WEB}/register`);
await page.waitForSelector("#email");
await page.locator("#email").fill("shufen@126.com");
await page.locator("#password").fill("dadao2026");
await page.locator("#confirm").fill("dadao2026");
await page.locator("#nickname").fill("淑芬");
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
await page.screenshot({ path: `${OUT}/register.png` });
await browser.close();
console.log(`${OUT}/register.png`);
+2
View File
@@ -5,6 +5,7 @@ import { UpdatePrompt } from "./components/UpdatePrompt";
import { api, authExpired, session } from "./lib/client"; import { api, authExpired, session } from "./lib/client";
import type { User, Workspace } from "./lib/types"; import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login"; import { LoginPage } from "./pages/Login";
import { RegisterPage } from "./pages/Register";
import { HomePage } from "./pages/Home"; import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace"; import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks"; import { TasksPage } from "./pages/Tasks";
@@ -89,6 +90,7 @@ export function App() {
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
+22 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../App"; import { useAuth } from "../App";
import { Book, Check, Logo, TicketIcon } from "../components/icons"; import { Book, Check, Logo, TicketIcon } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui"; import { Button, Field, InlineAlert } from "../components/ui";
@@ -34,7 +34,12 @@ const PHONE_RE = /^1[3-9]\d{9}$/;
const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/; const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/;
/** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */ /** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */
type Methods = { password: boolean; email_password: boolean; sms_code: boolean }; type Methods = {
password: boolean;
email_password: boolean;
sms_code: boolean;
email_register: boolean;
};
export function LoginPage() { export function LoginPage() {
const { reload } = useAuth(); const { reload } = useAuth();
@@ -46,7 +51,12 @@ export function LoginPage() {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("password"); const [mode, setMode] = useState<"code" | "password">("password");
const [methods, setMethods] = useState<Methods>({ password: true, email_password: true, sms_code: false }); const [methods, setMethods] = useState<Methods>({
password: true,
email_password: true,
sms_code: false,
email_register: true,
});
const [devCode, setDevCode] = useState<string | null>(null); const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -312,6 +322,15 @@ export function LoginPage() {
</Button> </Button>
</form> </form>
{methods.email_register && (
<p className="mt-6 text-sm text-ink-500">
还没有账号?
<Link className="ml-1 font-medium text-primary-700 hover:underline" to="/register">
用邮箱注册
</Link>
</p>
)}
<p className="mt-8 text-2xs leading-relaxed text-ink-400"> <p className="mt-8 text-2xs leading-relaxed text-ink-400">
登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。 登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。
</p> </p>
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Logo } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/;
/** 跟后端 `password_problem` 同一套规则;本地只做"别白跑一趟"的提示,真判定在服务端。 */
function passwordHint(password: string): string | null {
if (password.length < 8) return "至少 8 位";
if (password.length > 64) return "最多 64 位";
if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return "要同时有字母和数字";
return null;
}
/**
* 注册:邮箱 + 验证码 + 密码。
*
* 为什么必须先验一次邮箱:短信登录关掉以后,邮箱是账号名也是唯一联系方式,
* "随手填一个" 会让账号变成没人能找回的孤岛。
*/
export function RegisterPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [nickname, setNickname] = useState("");
const [countdown, setCountdown] = useState(0);
const [sent, setSent] = useState(false);
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(true);
useEffect(() => {
let alive = true;
api
.get<{ email_register: boolean }>("/auth/methods")
.then((data) => {
if (alive) setOpen(data.email_register);
})
.catch(() => {
/* 拿不到就按开着渲染,提交时服务端会给出真答案 */
});
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const emailValid = EMAIL_RE.test(email.trim());
const weak = password ? passwordHint(password) : null;
const mismatch = confirm.length > 0 && confirm !== password;
const canSubmit =
!busy && emailValid && code.length >= 4 && !weak && password.length > 0 && confirm === password;
async function sendCode() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ dev_code?: string }>("/auth/email/code", {
email: email.trim(),
});
setDevCode(data.dev_code ?? null);
setSent(true);
setCountdown(60);
} catch (err) {
setError(err instanceof ApiError ? err.message : "发送失败");
} finally {
setBusy(false);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/register", {
email: email.trim(),
code,
password,
...(nickname.trim() ? { nickname: nickname.trim() } : {}),
});
session.save(data.tokens);
await reload();
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "注册失败");
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-full items-center justify-center bg-paper-100 px-5 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center gap-2.5">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</div>
<h1 className="text-2xl font-semibold">创建账号</h1>
<p className="mt-1.5 text-sm text-ink-500">
用邮箱注册:先收一个验证码,再设一个密码。邮箱就是你的登录账号。
</p>
{!open && (
<div className="mt-5">
<InlineAlert>这台服务暂未开放自助注册,请联系管理员开通账号。</InlineAlert>
</div>
)}
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="邮箱">
<input
id="email"
className="input h-11"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={255}
disabled={!open}
/>
</Field>
<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}
disabled={!open}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!open || !emailValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : sent ? "重新发送" : "获取验证码"}
</Button>
</div>
</Field>
<Field label="密码">
<input
id="password"
type="password"
className="input h-11"
autoComplete="new-password"
placeholder="至少 8 位,字母 + 数字"
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={64}
disabled={!open}
/>
{weak && <span className="mt-1 block text-2xs text-clay-600">{weak}</span>}
</Field>
<Field label="确认密码">
<input
id="confirm"
type="password"
className="input h-11"
autoComplete="new-password"
placeholder="再输一次"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
maxLength={64}
disabled={!open}
/>
{mismatch && <span className="mt-1 block text-2xs text-clay-600">两次输入不一致</span>}
</Field>
<Field label="昵称(可不填)">
<input
id="nickname"
className="input h-11"
autoComplete="nickname"
placeholder="留空就用邮箱前缀"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={32}
disabled={!open}
/>
</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">
<span className="mt-px text-clay-500">开发环境验证码:{devCode}</span>
<span className="text-clay-600/70">(生产环境只发到邮箱,不回显)</span>
</div>
)}
{error && <InlineAlert>{error}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={!open || !canSubmit}
>
{busy ? "处理中…" : "注册并登录"}
</Button>
</form>
<p className="mt-6 text-sm text-ink-500">
已经有账号了?
<Link className="ml-1 font-medium text-primary-700 hover:underline" to="/login">
去登录
</Link>
</p>
</div>
</main>
);
}