短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
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:
+41
-5
@@ -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`(能不能进「系统设置」)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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`);
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user