feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s

用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
This commit is contained in:
Steven
2026-11-03 00:00:11 +08:00
parent 23687d6e37
commit 7558637567
29 changed files with 1702 additions and 93 deletions
+67 -7
View File
@@ -29,10 +29,14 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -65,6 +69,19 @@
## 1. 认证 `/auth`
登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染,
免得用户点进一条关着的路:
### `GET /auth/methods`
```json
{ "password": true, "email_password": true, "sms_code": false }
```
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
### `POST /auth/sms/send`
```json
@@ -72,7 +89,7 @@
```
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → `2010`。
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
### `POST /auth/login`
@@ -97,12 +114,19 @@
### `POST /auth/login/password`
手机号和邮箱都走这一个入口,`phone` 与 `email` **二选一**(都给或都不给 → 422):
```json
{ "phone": "13800000000", "password": "dadao2026" }
{ "email": "shufen@126.com", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。
(不区分账号是否存在,少一个探测面;手机号与邮箱两种账号也不会互相暴露)。15 分钟内连错 10 次 → `2016`,
期间正确密码也挡住,计数键是"手机号或邮箱"本身。
邮箱按 `lower(email)` 比较,绑定与登录都做 `strip` + 小写归一,所以 `Shufen@126.com` 与
`shufen@126.com` 是同一个账号。
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
@@ -119,15 +143,51 @@
### `PATCH /auth/me`
```json
{ "nickname": "大道" }
{ "nickname": "大道", "email": "shufen@126.com" }
```
返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
字段都可选,只改传上来的。邮箱留空串表示**解绑**(之后不能再用邮箱登录)。
格式不对 → `2018`,已被别的账号占用 → `2019`。返回 `{ "user": { … } }`。
空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、`platform_admin`
(能不能进「系统设置」)。
---
## 1.5 系统设置 `/settings`
平台级配置,**全站一份、不挂租户**,只有 `PLATFORM_ADMIN_PHONES` 里的人能读写(否则 `3004`)。
目前只有发信邮箱一项——它承载找回密码与以后的邮件通知,属于"改一次全站受影响"的东西,
放后台可改而不用登服务器改 env。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/settings/smtp` | 当前配置(**不含授权码**) |
| PUT | `/settings/smtp` | 保存,立即生效,不重启 |
| POST | `/settings/smtp/test` | 发一封测试邮件,确认通道通不通 |
```json
// PUT /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
```
`password` 传 `null` 或空串表示**沿用旧值**(改别的字段时不用重填授权码)。
```json
// GET /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password_set": true, "from_name": "达道Task", "use_ssl": true,
"configured": true, "source": "db", "updated_at": "2026-11-02T12:00:00Z" }
```
`source` 说明当前值来自数据库(`db`)、服务器环境变量兜底(`env`),还是根本没配(`none`)。
发信失败统一 `2020`(`502`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
**客户端授权码**,不是登录密码。
---
+51
View File
@@ -541,3 +541,54 @@
也看不到安装提示(桌面用 `127.0.0.1` 打开可以)。要么加域名 + 证书反代(`443 → 127.0.0.1:8200`),
要么用 Cloudflare Tunnel 之类拿个 https 域名;应用代码不用改。已写进 `DEPLOY.md` 第 6 节
- 安卓端还在走自己的 OTA(原生包),和 PWA 是两条路;要不要让安卓也改成加载 Web,等产品定
---
## 9. 登录方式与系统设置(2026-11-02)
### 9.1 需求
用户原话:"手机验证码我暂时不启动,所以把12345这种测试验证码取消了吧,我用账号密码来登陆……
再增加一种登陆方式就是邮箱……并且这个邮箱是可以后台配置的啊;"(同时要求三个列表的标题样式统一)
### 9.2 决定
- **短信登录默认关**(`SMS_LOGIN_ENABLED=false`):dev 环境的固定码等于"知道手机号就能进任何账号"。
关掉后 `/auth/sms/send` 与 `/auth/login` 统一回 `2017`,不是静默失败;登录页按 `GET /auth/methods`
渲染,关着的路根本不显示(少一次"点进去才发现不能用")。
- **邮箱是账号名**,不是通知地址:绑了就能用「邮箱 + 密码」登录,`lower(email)` 唯一。
- **SMTP 存数据库、后台可改**:发信账号是平台级运维参数,改它不该要人登服务器改 env 再重启。
只有平台管理员(`PLATFORM_ADMIN_PHONES` 白名单)能读写,因为系统设置是跨租户的。
- 密码哈希继续用标准库 scrypt,不引 passlib/bcrypt(多一个升级债,换不来安全)。
### 9.3 后端
| 位置 | 内容 |
|---|---|
| `app/config.py` | `sms_login_enabled=False`、`platform_admin_phones`(`NoDecode` + CSV 拆分,别让 pydantic 按 JSON 解析)、`smtp_*` 兜底字段;生产校验改为"开着短信登录就必须是真通道" |
| `app/security.py` | `hash_password` / `verify_password` / `password_problem`(scrypt,8–64 位且字母+数字) |
| `app/models.py` | `AppUser.email`;新表 `system_setting(key, value JSONB, updated_by)`,**不挂租户、无 RLS**,写权限靠 `deps.platform_admin` |
| `alembic/versions/0010_email_and_settings.py` | `system_setting` 建表 + `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引)+ GRANT |
| `app/services/mail.py` | 读库优先、环境变量兜底;465 走 SSL、587 走 STARTTLS;认证失败/收件人被拒/网络失败都归 `2020` 但消息不同 |
| `app/api/auth.py` | `GET /auth/methods`、`POST /auth/login/password`(`phone`/`email` 二选一)、`PATCH /auth/me`(改昵称/绑解绑邮箱)、`sms` 两条加开关闸门 |
| `app/api/settings.py` | `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,全部 `PlatformAdminDep` |
| `app/deps.py` | `platform_admin` / `PlatformAdminDep` |
### 9.4 Web
- `pages/Login.tsx`:先问 `/auth/methods` 再渲染;账号档「手机号 / 邮箱」,密码框;短信关着时不出现「验证码」档
- `pages/Settings.tsx`(新):SMTP 表单(服务器/端口/账号/授权码/显示名/SSL)+ 当前状态卡(能不能发信、授权码设没设、上次修改、来源 db/env)+ 测试发信
- `pages/Account.tsx`:基本信息加「邮箱」(说明"留空保存就是解绑")、按钮改「保存资料」、平台管理员多一个「系统设置」入口卡
- `components/SideRail.tsx`:平台管理员才多一条「系统设置」(移动端顶条同理),图标 `Sliders`
- `pages/Tasks.tsx:262` / `pages/Tickets.tsx:248`:列表标题统一成 `text-[15px] font-semibold leading-6`(与知识一致)
### 9.5 验证
- `backend/tests/test_login_email.py`(7 例)+ `test_settings_smtp.py`(6 例)+ 改写的 `test_config_prod.py`;
全量 `pytest` 130 passed、`ruff check` / `format --check` 全绿
- `web/e2e/login-email-smtp.mjs`(**15 项全绿**):methods 说短信关着 → 发码回 2017 → 登录页没有验证码档 →
手机号密码进首页 → 绑邮箱 → 邮箱登录(大小写不敏感)→ 解绑后回 2013 → 侧栏出现「系统设置」 →
存授权码(接口不回显)→ **真发一封测试邮件成功** → 账号页回填邮箱 → 控制台零报错
- 截图:`2.Design/screens/web-login-password.png`、`web-account-email.png`、`web-settings-smtp.png`
### 9.6 踩过的坑
- `platform_admin_phones` 是 `list[str]`,pydantic-settings 会把它当 JSON 解析,`A,B` 直接启动失败:
用 `Annotated[list[str], NoDecode]` 交给 before-validator 拆逗号
- 迁移号写成 `0009` 会撞 `0009_knowledge_category_color.py`,pytest 报 `Multiple head revisions`
- e2e 里 `getByLabel("账号")` 会同时命中侧栏 `aria-label="账号设置"`,要用 `input[aria-label="账号"]`;
侧栏的「系统设置」要等 `/auth/me` 落地才渲染,断言得 `waitForFunction` 而不是立刻 `count()`
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,67 @@
"""邮箱登录 + 平台设置(M1 账号功能)
Revision ID: 0010
Revises: 0009
Create Date: 2026-11-02
两件事:
1. `app_user.email`:邮箱当账号名,配合密码登录。唯一性用 `lower(email)` 的部分
唯一索引——邮箱大小写不敏感,`Steven@x.com` 和 `steven@x.com` 是同一个人;
允许为空(老用户先不绑)。
2. `system_setting`:平台级键值表,第一项就是 SMTP。**这张表刻意不挂 workspace_id、
也不开 RLS**:发信账号是整台服务共用的,属于平台而不是某个租户,开 RLS 反而要
编一个"当前租户"出来。写权限在 API 层用平台管理员白名单把关(deps.platform_admin),
应用角色只有表的读写权,没有建表/改结构的权。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0010"
down_revision: str = "0009"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS system_setting (
key VARCHAR(64) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES app_user(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version INTEGER NOT NULL DEFAULT 1
)
"""
INDEX_STATEMENTS = (
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
"CREATE UNIQUE INDEX uq_app_user_email ON app_user (lower(email)) WHERE email IS NOT NULL",
"COMMENT ON TABLE system_setting IS '平台级设置(SMTP 等),不挂租户、无 RLS'",
)
GRANT_SQL = (
f"GRANT SELECT, INSERT, UPDATE, DELETE ON system_setting TO {APP_ROLE}",
f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in GRANT_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_app_user_email")
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email")
op.execute("DROP TABLE IF EXISTS system_setting")
+94 -18
View File
@@ -1,17 +1,20 @@
"""认证:手机号 + 验证码,可选登录密码。
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。
"""
from __future__ import annotations
import hmac
import re
import secrets
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func, select
from ..config import get_settings
from ..deps import CurrentUser, SessionDep
@@ -31,6 +34,8 @@ router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
@@ -41,14 +46,24 @@ def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def normalize_email(email: str) -> str:
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
return email.strip().lower()
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"email": user.email,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"platform_admin": user.phone in settings.platform_admin_phones,
"created_at": user.created_at,
}
@@ -72,12 +87,23 @@ class LoginIn(BaseModel):
class PasswordLoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=1, max_length=64)
@model_validator(mode="after")
def _one_account(self) -> PasswordLoginIn:
if not self.phone and not (self.email and self.email.strip()):
raise ValueError("手机号或邮箱填一个")
return self
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
# 传空串表示解绑;不传表示不动
email: str | None = Field(default=None, max_length=255)
class PasswordIn(BaseModel):
@@ -91,8 +117,25 @@ class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
async def methods() -> dict:
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
return ok(
{
"password": True,
"email_password": True,
"sms_code": settings.sms_login_enabled,
}
)
@router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
if settings.sms_throttle_seconds > 0:
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
@@ -115,6 +158,11 @@ async def send_code(body: SendCodeIn) -> dict:
@router.post("/login", summary="验证码登录(不存在则自动注册)")
async def login(body: LoginIn, session: SessionDep) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
stored = await store.get(sms_code_key(body.phone))
if stored is None:
@@ -135,28 +183,36 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/login/password", summary="密码登录")
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"""手机号 + 密码登录。
"""手机号或邮箱 + 密码登录。
两个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
三个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
"""
store = get_store()
fail_key = f"pwd_fail:{body.phone}"
account_key = body.phone or normalize_email(body.email or "")
fail_key = f"pwd_fail:{account_key}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
)
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if body.phone:
stmt = select(AppUser).where(AppUser.phone == body.phone)
hint = "手机号或密码不对"
else:
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
hint = "邮箱或密码不对"
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对")
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
@@ -207,7 +263,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
)
@router.patch("/me", summary="改自己的昵称")
@router.patch("/me", summary="改自己的昵称与邮箱")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
@@ -219,6 +275,26 @@ async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep)
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
if body.email is not None:
raw = body.email.strip()
if not raw:
# 解绑:邮箱只是"多一条登录方式",不是必填项
row.email = None
else:
email = normalize_email(raw)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
taken = (
await session.execute(
select(AppUser.id).where(
func.lower(AppUser.email) == email, AppUser.id != user.id
)
)
).scalar_one_or_none()
if taken is not None:
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
row.email = email
await session.flush()
return ok({"user": _user_out(row)})
+83
View File
@@ -0,0 +1,83 @@
"""系统设置:平台级的运维参数(目前只有 SMTP)。
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..deps import PlatformAdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser
from ..schemas import ok
from ..services import mail
router = APIRouter(prefix="/settings", tags=["系统设置"])
class SmtpIn(BaseModel):
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=465, ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
password: str | None = Field(default=None, max_length=255)
from_name: str = Field(default="达道Task", max_length=64)
use_ssl: bool = True
class TestMailIn(BaseModel):
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
to: str | None = Field(default=None, max_length=255)
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
return ok(await mail.config_payload(session))
@router.put("/smtp", summary="改发信配置,存库即时生效")
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
await mail.save_config(
session,
values={
"host": body.host.strip(),
"port": body.port,
"username": body.username.strip(),
"password": (body.password or "").strip() or None,
"from_name": body.from_name.strip() or "达道Task",
"use_ssl": body.use_ssl,
},
updated_by=admin.id,
)
return ok(await mail.config_payload(session))
@router.post("/smtp/test", summary="发一封测试邮件")
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
to = (body.to or "").strip()
if not to:
row = await session.get(AppUser, admin.id)
to = (row.email if row else None) or ""
if not to:
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
cfg = await mail.load_config(session)
if cfg is None:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
await mail.send_mail(
cfg,
to=to,
subject="达道Task 测试邮件",
text=(
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
),
)
return ok({"sent": True, "to": to})
+31 -7
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from typing import Annotated, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
@@ -60,6 +60,30 @@ class Settings(BaseSettings):
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
sms_login_enabled: bool = False
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["13605513683"]
)
# ── 邮件(SMTP)────────────────────────────────────────
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
smtp_host: str | None = None
smtp_port: int = 465
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from_name: str = "达道Task"
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
smtp_use_ssl: bool = True
smtp_timeout_seconds: int = 10
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
@@ -117,10 +141,10 @@ class Settings(BaseSettings):
build_commit: str = "unknown"
build_time: str = "unknown"
@field_validator("cors_origins", mode="before")
@field_validator("cors_origins", "platform_admin_phones", mode="before")
@classmethod
def _split_origins(cls, value: object) -> object:
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
if isinstance(value, str) and not value.startswith("["):
return [item.strip() for item in value.split(",") if item.strip()]
return value
@@ -134,10 +158,10 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev" and not self.internal_test_login:
if self.sms_login_enabled and self.sms_provider == "dev":
problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+16
View File
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import get_settings
from .db import bind_tenant, bind_user, get_session
from .errors import AppError, ErrorCode
from .logging_setup import user_id_ctx, workspace_id_ctx
@@ -112,3 +113,18 @@ def require_role(minimum: str):
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
async def platform_admin(user: CurrentUser) -> AuthUser:
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
"""
if user.phone not in get_settings().platform_admin_phones:
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
return user
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
+8
View File
@@ -29,6 +29,10 @@ class ErrorCode(IntEnum):
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
LOGIN_METHOD_DISABLED = 2017
EMAIL_INVALID = 2018
EMAIL_TAKEN = 2019
MAIL_SEND_FAILED = 2020
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -94,6 +98,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.LOGIN_METHOD_DISABLED: 403,
ErrorCode.EMAIL_INVALID: 422,
ErrorCode.EMAIL_TAKEN: 409,
ErrorCode.MAIL_SEND_FAILED: 502,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
+4 -2
View File
@@ -17,6 +17,7 @@ from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
from .api import settings as settings_api
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -31,9 +32,9 @@ logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
if settings.env == "prod" and settings.sms_login_enabled:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info(
@@ -116,6 +117,7 @@ app.include_router(tickets.notify_router, prefix=settings.api_prefix)
app.include_router(knowledge.router, prefix=settings.api_prefix)
app.include_router(attachments.router, prefix=settings.api_prefix)
app.include_router(app_release.router, prefix=settings.api_prefix)
app.include_router(settings_api.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+19
View File
@@ -47,6 +47,25 @@ class AppUser(Base, Timestamps):
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255))
class SystemSetting(Base, Timestamps):
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
"""
__tablename__ = "system_setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
class Workspace(Base, Timestamps):
+202
View File
@@ -0,0 +1,202 @@
"""发信(SMTP)。
三个刻意的取舍:
1. **配置以数据库里的为准**。发信账号是平台级的运维参数,改它不该要人登服务器
改 env 再重启;所以「系统设置」页写库,这里每次读库、读不到才回落到环境变量。
2. **只用标准库 smtplib**。发信不是性能路径,引一个三方 SDK 换来的只是多一条升级债;
SSL / STARTTLS 两种握手都覆盖了(465 走 SSL,587 走 STARTTLS)。
3. **失败要能说清是哪种失败**。连不上、认证不过、被对方拒收,对使用者是三件不同的
事;统一报"发信失败"会让人只能去翻服务器日志。所以异常分类后带进错误码 2020。
"""
from __future__ import annotations
import asyncio
import contextlib
import smtplib
import ssl
from dataclasses import dataclass
from email.header import Header
from email.message import EmailMessage
from email.utils import formataddr, formatdate
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..errors import AppError, ErrorCode
from ..models import SystemSetting
SMTP_KEY = "smtp"
# 存库的字段名与对外字段一致,避免来回翻译出现第三个名字
SMTP_FIELDS = ("host", "port", "username", "password", "from_name", "use_ssl")
@dataclass(frozen=True)
class SmtpConfig:
host: str
port: int
username: str
password: str
from_name: str = "达道Task"
use_ssl: bool = True
@property
def sender(self) -> str:
return formataddr((str(Header(self.from_name, "utf-8")), self.username))
@property
def configured(self) -> bool:
return bool(self.host and self.username and self.password)
def _from_settings() -> SmtpConfig | None:
s = get_settings()
if not (s.smtp_host and s.smtp_username and s.smtp_password):
return None
return SmtpConfig(
host=s.smtp_host,
port=s.smtp_port,
username=s.smtp_username,
password=s.smtp_password,
from_name=s.smtp_from_name,
use_ssl=s.smtp_use_ssl,
)
async def load_config(session: AsyncSession) -> SmtpConfig | None:
"""先看库里有没有人配过,没有再回落到环境变量。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
if not (data.get("host") and data.get("username") and data.get("password")):
return _from_settings()
return SmtpConfig(
host=str(data["host"]),
port=int(data.get("port") or 465),
username=str(data["username"]),
# 授权码里可能有多余空格,粘贴时最常见的就是把尾巴带上
password=str(data["password"]).strip(),
from_name=str(data.get("from_name") or "达道Task"),
use_ssl=bool(data.get("use_ssl", True)),
)
async def save_config(session: AsyncSession, *, values: dict, updated_by) -> SmtpConfig:
"""写库。password 传空表示"沿用原来的",不传等于没改。"""
row = await session.get(SystemSetting, SMTP_KEY)
current = dict(row.value or {}) if row else {}
merged = {**current, **{k: v for k, v in values.items() if v is not None}}
if not values.get("password"):
merged["password"] = current.get("password", "")
if not (merged.get("host") and merged.get("username") and merged.get("password")):
raise AppError(ErrorCode.VALIDATION_FAILED, "SMTP 服务器、账号、授权码都要填")
if row is None:
row = SystemSetting(key=SMTP_KEY, value=merged, updated_by=updated_by)
session.add(row)
else:
row.value = merged
row.updated_by = updated_by
await session.flush()
cfg = SmtpConfig(
host=str(merged["host"]),
port=int(merged.get("port") or 465),
username=str(merged["username"]),
password=str(merged["password"]).strip(),
from_name=str(merged.get("from_name") or "达道Task"),
use_ssl=bool(merged.get("use_ssl", True)),
)
return cfg
def _send_sync(cfg: SmtpConfig, to: str, subject: str, text: str, html: str | None) -> None:
msg = EmailMessage()
msg["Subject"] = str(Header(subject, "utf-8"))
msg["From"] = cfg.sender
msg["To"] = to
msg["Date"] = formatdate(localtime=True)
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
s = get_settings()
timeout = s.smtp_timeout_seconds
context = ssl.create_default_context()
if cfg.use_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(
cfg.host, cfg.port, timeout=timeout, context=context
)
else:
server = smtplib.SMTP(cfg.host, cfg.port, timeout=timeout)
server.starttls(context=context)
try:
server.login(cfg.username, cfg.password)
server.send_message(msg)
finally:
# 信已经发出去了,QUIT 失败不该把成功回报成失败
with contextlib.suppress(Exception):
server.quit()
async def send_mail(
session_or_cfg: AsyncSession | SmtpConfig,
*,
to: str,
subject: str,
text: str,
html: str | None = None,
) -> None:
"""发一封纯文本(可带 HTML 替代体)的信。失败一律抛 2020,并说清是哪一步。"""
cfg = (
await load_config(session_or_cfg)
if isinstance(session_or_cfg, AsyncSession)
else session_or_cfg
)
if cfg is None or not cfg.configured:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先在系统设置里填 SMTP")
try:
await asyncio.to_thread(_send_sync, cfg, to, subject, text, html)
except smtplib.SMTPAuthenticationError as exc:
raise AppError(
ErrorCode.MAIL_SEND_FAILED,
"SMTP 认证失败:账号或授权码不对(126/163 要用客户端授权码,不是登录密码)",
) from exc
except smtplib.SMTPRecipientsRefused as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"收件地址被拒绝:{to}") from exc
except (smtplib.SMTPException, OSError) as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"发信失败:{exc}") from exc
async def config_payload(session: AsyncSession) -> dict:
"""给设置页看的形状:授权码只回"设没设",不回原值。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
cfg = await load_config(session)
if cfg is None:
return {
"host": data.get("host") or "",
"port": int(data.get("port") or 465),
"username": data.get("username") or "",
"password_set": bool(data.get("password")),
"from_name": data.get("from_name") or "达道Task",
"use_ssl": bool(data.get("use_ssl", True)),
"configured": False,
"source": "none",
"updated_at": row.updated_at if row else None,
}
return {
"host": cfg.host,
"port": cfg.port,
"username": cfg.username,
"password_set": True,
"from_name": cfg.from_name,
"use_ssl": cfg.use_ssl,
"configured": cfg.configured,
"source": "db" if row else "env",
"updated_at": row.updated_at if row else None,
}
+4 -1
View File
@@ -13,6 +13,9 @@ os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456")
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
# 这里不再改 os.environ —— 配置对象自己会读 .env。
@@ -36,7 +39,7 @@ from app.stores import reset_store_for_tests
settings = get_settings()
DEV_CODE = settings.sms_dev_code
_TABLES = "audit_log, invitation, member, workspace, app_user"
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
def _run_migrations() -> None:
+2
View File
@@ -29,9 +29,11 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient)
assert set(data["user"]) == {
"id",
"phone",
"email",
"nickname",
"avatar_url",
"has_password",
"platform_admin",
"created_at",
}
+12 -6
View File
@@ -1,6 +1,8 @@
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
现在的规则更简单——**短信验证码登录默认关着**,关着时 SMS_PROVIDER 是 dev 也无所谓,
因为那条路根本不通;只有在 SMS_LOGIN_ENABLED=true 时才要求真通道。
"""
from __future__ import annotations
@@ -24,14 +26,18 @@ def _prod(**overrides: object) -> Settings:
return Settings(**values)
def test_prod_rejects_dev_sms_without_switch() -> None:
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
_prod(sms_provider="dev").validate_runtime()
def test_prod_rejects_dev_sms_when_login_enabled() -> None:
with pytest.raises(RuntimeError, match="SMS_LOGIN_ENABLED"):
_prod(sms_provider="dev", sms_login_enabled=True).validate_runtime()
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
def test_prod_is_fine_with_sms_login_closed() -> None:
# 默认就是这条:短信登录关着,provider 配成 dev 也不会有人能用它进来
_prod(sms_provider="dev", sms_login_enabled=False).validate_runtime()
def test_prod_allows_real_sms_provider_when_enabled() -> None:
_prod(sms_provider="aliyun", sms_login_enabled=True).validate_runtime()
def test_prod_still_rejects_weak_jwt_secret() -> None:
+131
View File
@@ -0,0 +1,131 @@
"""登录方式的开关,以及用邮箱登录。
三条是这个功能的重点,也是测试的重点:
1. **短信验证码能整体关掉**。关掉后连"发码"这一步都不该通——否则等于留了个后门:
内置验证码仍然对得上,只是没人去点。
2. **邮箱是账号名,不是联系方式**:绑定之后能用它登录,改绑立刻生效、解绑立刻失效。
3. **邮箱唯一且大小写不敏感**:`Shufen@126.com` 和 `shufen@126.com` 是同一个人,
不能让第二个账号绑走。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from .conftest import login, unwrap
settings = get_settings()
async def _set_password(client: AsyncClient, user: dict, password: str) -> None:
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": password}, headers=user["headers"]
)
)
async def _bind_email(client: AsyncClient, user: dict, email: str) -> dict:
return unwrap(
await client.patch("/api/v1/auth/me", json={"email": email}, headers=user["headers"])
)
async def test_methods_lists_what_is_open(client: AsyncClient) -> None:
data = unwrap(await client.get("/api/v1/auth/methods"))
assert data["password"] is True
assert data["email_password"] is True
# 测试环境把短信开着(夹具要靠它造用户);线上默认是 false
assert data["sms_code"] is True
async def test_sms_login_can_be_closed(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "sms_login_enabled", False)
methods = unwrap(await client.get("/api/v1/auth/methods"))
assert methods["sms_code"] is False
sent = await client.post("/api/v1/auth/sms/send", json={"phone": "13800138000"})
assert sent.json()["code"] == 2017
# 就算手里还捏着旧验证码,登录这一步也必须关死
logged = await client.post(
"/api/v1/auth/login", json={"phone": "13800138000", "code": settings.sms_dev_code}
)
assert logged.json()["code"] == 2017
async def test_bind_email_and_login_with_it(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
data = await _bind_email(client, user, " Shufen@126.com ")
# 存的是小写,避免大小写绕出第二个账号
assert data["user"]["email"] == "shufen@126.com"
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"email": "SHUFEN@126.COM", "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]
async def test_email_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
resp = await client.post(
"/api/v1/auth/login/password", json={"email": "shufen@126.com", "password": "nope-nope-1"}
)
assert resp.json()["code"] == 2013
async def test_email_is_validated_and_unique(client: AsyncClient) -> None:
first = await login(client)
bad = await client.patch(
"/api/v1/auth/me", json={"email": "not-an-email"}, headers=first["headers"]
)
assert bad.json()["code"] == 2018
await _bind_email(client, first, "shufen@126.com")
second = await login(client)
taken = await client.patch(
"/api/v1/auth/me", json={"email": "SHUFEN@126.com"}, headers=second["headers"]
)
assert taken.json()["code"] == 2019
async def test_unbind_email_then_login_by_email_fails(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
cleared = await _bind_email(client, user, "")
assert cleared["user"]["email"] is None
resp = await client.post(
"/api/v1/auth/login/password",
json={"email": "shufen@126.com", "password": "Dadao2026pw"},
)
assert resp.json()["code"] == 2013
async def test_phone_login_still_works(client: AsyncClient) -> None:
"""老客户端/安卓只发 phone,这条路不能因为加了邮箱就断。"""
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]
+130
View File
@@ -0,0 +1,130 @@
"""系统设置(SMTP):只有平台管理员能改,授权码不回显,改完立刻生效。
为什么值得单独测:这是全站唯一一个**跨租户**的写接口。租户隔离测试管不到它,
一旦漏了权限判断,任何登录用户都能改掉全平台发信账号(等于劫持找回密码邮件)。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from app.services import mail
from .conftest import login, unwrap
settings = get_settings()
SMTP = {
"host": "smtp.126.com",
"port": 465,
"username": "shufenhf@126.com",
"password": "CH46ngqQ7Z2XE2eZ",
"from_name": "达道Task",
"use_ssl": True,
}
async def _admin(client: AsyncClient) -> dict:
"""白名单里的手机号登录,就是平台管理员。"""
user = await login(client, phone="13605513683")
return user
async def test_settings_require_platform_admin(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
outsider = await login(client)
read = await client.get("/api/v1/settings/smtp", headers=outsider["headers"])
assert read.json()["code"] == 3004
write = await client.put("/api/v1/settings/smtp", json=SMTP, headers=outsider["headers"])
assert write.json()["code"] == 3004
async def test_platform_admin_flag_comes_back_in_me(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
outsider = await login(client)
me_admin = unwrap(await client.get("/api/v1/auth/me", headers=admin["headers"]))
me_outsider = unwrap(await client.get("/api/v1/auth/me", headers=outsider["headers"]))
assert me_admin["user"]["platform_admin"] is True
assert me_outsider["user"]["platform_admin"] is False
async def test_save_smtp_hides_password_and_persists(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
empty = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert empty["configured"] is False
saved = unwrap(await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"]))
assert saved["configured"] is True
assert saved["source"] == "db"
assert saved["host"] == "smtp.126.com"
assert saved["password_set"] is True
# 授权码一个字节都不能回给页面
assert "password" not in saved
assert "CH46ngqQ7Z2XE2eZ" not in str(saved)
again = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert again["username"] == "shufenhf@126.com"
assert again["port"] == 465
async def test_saving_without_password_keeps_old_one(client: AsyncClient, monkeypatch) -> None:
"""授权码留空 = 沿用原来的。否则每次改端口都得重新粘贴一遍授权码。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
payload = {**SMTP, "port": 587, "use_ssl": False, "password": ""}
resp = await client.put("/api/v1/settings/smtp", json=payload, headers=admin["headers"])
saved = unwrap(resp)
assert saved["port"] == 587
assert saved["password_set"] is True
from app.db import SessionFactory
async with SessionFactory() as session:
cfg = await mail.load_config(session)
assert cfg is not None
assert cfg.password == "CH46ngqQ7Z2XE2eZ"
assert cfg.port == 587
async def test_test_mail_reports_missing_config(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
resp = await client.post(
"/api/v1/settings/smtp/test", json={"to": "steven@example.com"}, headers=admin["headers"]
)
assert resp.json()["code"] == 2020
async def test_test_mail_actually_sends(client: AsyncClient, monkeypatch) -> None:
"""真发信在这里被替换掉——测的是"取到配置、走对收件人",不是 SMTP 本身。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
sent: list[tuple] = []
def fake_send(cfg, to, subject, text, html): # noqa: ANN001 - 与真实签名一致
sent.append((cfg.host, cfg.username, to, subject, text))
monkeypatch.setattr(mail, "_send_sync", fake_send)
data = unwrap(
await client.post(
"/api/v1/settings/smtp/test",
json={"to": "steven@example.com"},
headers=admin["headers"],
)
)
assert data["sent"] is True
assert data["to"] == "steven@example.com"
assert sent and sent[0][0] == "smtp.126.com"
assert sent[0][1] == "shufenhf@126.com"
+221
View File
@@ -0,0 +1,221 @@
/**
* 登录方式与系统设置的验收:短信关着、邮箱能登录、SMTP 后台可配。
*
* 三个都要真跑,因为这三件事全是"接口回 200 但人用不了"的高危区:
* - 短信关着,登录页就不能出现「验证码」这一档(不然用户点进去只会看到 2017);
* - 邮箱 + 密码要能真的登进来,不是只把邮箱存进库;
* - SMTP 存进库之后要能立刻发信,别等到真要用的时候才发现授权码是错的。
*
* 授权码不进仓库:发信那一步只在给了 SMTP_PASS 环境变量时才跑。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
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 PHONE = process.env.PHONE ?? "13900000001";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
const SMTP_HOST = process.env.SMTP_HOST ?? "smtp.126.com";
const SMTP_USER = process.env.SMTP_USER ?? "";
const SMTP_PASS = process.env.SMTP_PASS ?? "";
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}`);
}
}
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim() || null;
} catch {
return null;
}
}
async function post(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body ?? {}),
});
return r.json();
}
async function get(path, token) {
const r = await fetch(`${API}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return r.json();
}
async function patch(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
return r.json();
}
async function main() {
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 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
const password = savedPassword();
// ① 服务端说清楚现在开着哪几条路
const methods = await get("/auth/methods");
check(
"① /auth/methods 说短信关着、密码与邮箱密码开着",
methods.code === 0 &&
methods.data.password === true &&
methods.data.email_password === true &&
methods.data.sms_code === false,
JSON.stringify(methods.data),
);
// ② 关着的那条路真的挡住了
const sms = await post("/auth/sms/send", { phone: PHONE });
check("② 发验证码的接口回 2017(不是静默成功)", sms.code === 2017, JSON.stringify(sms));
// ③ 登录页不该再出现「验证码」这一档
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
const codeTab = await page.getByRole("button", { name: "验证码", exact: true }).count();
const emailTab = await page.getByRole("tab", { name: "邮箱" }).count();
const pwdInput = await page.locator("#password").count();
check("③ 登录页没有验证码那一档、有邮箱档与密码框", codeTab === 0 && emailTab === 1 && pwdInput === 1);
// ④ 手机号 + 密码,从界面登录进去
let loggedIn = false;
if (password) {
await page.getByLabel("手机号").fill(PHONE);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
loggedIn = await page
.waitForURL(`${WEB}/`, { timeout: 8000 })
.then(() => true)
.catch(() => false);
}
check("④ 手机号 + 密码能从登录页进首页", loggedIn, "(缺 .local/e2e-account-password.txt 时跳过)");
const tokens = loggedIn
? await page.evaluate(() => localStorage.getItem("dadao.access"))
: (await post("/auth/login/password", { phone: PHONE, password })).data?.tokens?.access_token;
// ⑤ 绑定邮箱,然后**用邮箱登录**
const email = `dadao-e2e-${Date.now()}@example.com`;
const bind = await patch("/auth/me", { email }, tokens);
check("⑤ 账号页能绑邮箱", bind.code === 0 && bind.data.user.email === email, JSON.stringify(bind));
const byEmail = await post("/auth/login/password", { email: email.toUpperCase(), password });
const emailLogin = await post("/auth/login/password", { email, password });
check(
"⑥ 邮箱 + 密码能登录(大小写不敏感)",
emailLogin.code === 0 && emailLogin.data?.tokens?.access_token,
JSON.stringify(emailLogin),
);
check("⑥b 全大写邮箱也能登录", byEmail.code === 0, JSON.stringify(byEmail));
// ⑦ 解绑之后这条路就该关掉,而不是留个后门
const unbind = await patch("/auth/me", { email: "" }, tokens);
const afterUnbind = await post("/auth/login/password", { email, password });
check(
"⑦ 解绑邮箱后邮箱登录回 2013",
unbind.code === 0 && unbind.data.user.email === null && afterUnbind.code === 2013,
JSON.stringify(afterUnbind),
);
// ⑧ 平台管理员才看得到「系统设置」
const me = await get("/auth/me", tokens);
check("⑧ 当前账号被标成平台管理员", me.data?.user?.platform_admin === true, JSON.stringify(me.data?.user));
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="昵称"]');
// 侧栏按 /auth/me 的 platform_admin 决定要不要渲染这一条,等它落下来再数
const settingsLink = await page
.waitForFunction(() => document.querySelectorAll('aside a[href="/settings"]').length, {
timeout: 5000,
})
.then((handle) => handle.jsonValue())
.catch(() => 0);
check("⑧b 侧栏里有「系统设置」入口", settingsLink === 1, `count=${settingsLink}`);
await page.screenshot({ path: `${OUT}/settings-entry.png` });
// ⑨ 系统设置页能读、能存;授权码只写不读
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
const before = await get("/settings/smtp", tokens);
check("⑨ 系统设置页读得到当前配置", before.code === 0 && before.data.password_set !== undefined);
if (SMTP_PASS) {
await page.getByLabel("服务器地址").fill(SMTP_HOST);
await page.getByLabel("端口").fill("465");
await page.locator('input[aria-label="账号"]').fill(SMTP_USER);
await page.getByLabel("授权码").fill(SMTP_PASS);
await page.getByRole("button", { name: "保存", exact: true }).click();
await page.waitForTimeout(1200);
const saved = await get("/settings/smtp", tokens);
check(
"⑩ 授权码存进库了,但接口不回显",
saved.data?.password_set === true &&
saved.data?.configured === true &&
!JSON.stringify(saved.data).includes(SMTP_PASS),
JSON.stringify(saved.data),
);
// ⑪ 真发一封,确认这条通道是通的(不是"存了个假配置")
const sent = await post("/settings/smtp/test", { to: SMTP_USER }, tokens);
check("⑪ 测试邮件发送成功", sent.code === 0, JSON.stringify(sent));
if (sent.code !== 0) {
console.log(` 发信失败原文:${sent.message}`);
}
await page.screenshot({ path: `${OUT}/settings-smtp.png` });
} else {
console.log("SKIP ⑩⑪ 没给 SMTP_PASS,跳过保存与发信");
}
// ⑫ 账号页上的邮箱框要能回填刚绑的值(说明前端读的是接口,不是本地缓存)
const rebind = await patch("/auth/me", { email }, tokens);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
const emailInput = await page.waitForFunction(
() => {
const el = document.querySelector('input[aria-label="邮箱"]');
return el && el.value.includes("@") ? el.value : null;
},
{ timeout: 5000 },
);
const shownEmail = await emailInput.jsonValue();
check("⑫ 账号页回填刚绑的邮箱", rebind.code === 0 && shownEmail === email, shownEmail);
check("⑬ 控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("脚本异常", err);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
/**
* 拍两张文档用的截图:登录页(两种账号名 + 密码)、账号页(邮箱)、系统设置页。
* 需要前端 5180、后端 8100 起着;账号用 dev 的平台管理员。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
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 PHONE = process.env.PHONE ?? "13900000001";
const OUT = "e2e/artifacts";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim();
} catch {
return "";
}
}
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 } });
mkdirSync(OUT, { recursive: true });
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
await page.screenshot({ path: `${OUT}/login-password.png` });
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password: savedPassword() }),
});
const data = await r.json();
await page.addInitScript(
([a, rf]) => {
localStorage.setItem("dadao.access", a);
localStorage.setItem("dadao.refresh", rf);
},
[data.data.tokens.access_token, data.data.tokens.refresh_token],
);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/account-page.png` });
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
await page.waitForTimeout(800);
await page.screenshot({ path: `${OUT}/settings-page.png` });
await browser.close();
console.log("screenshots written to", OUT);
+2
View File
@@ -14,6 +14,7 @@ import { KnowledgePage } from "./pages/Knowledge";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
import { SettingsPage } from "./pages/Settings";
interface AuthState {
user: User | null;
@@ -90,6 +91,7 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
+20 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { useAuth } from "../App";
import { Logout, Logo, Settings } from "./icons";
import { Logout, Logo, Settings, Sliders } from "./icons";
/**
* 工作区里侧栏长什么样,工作区外面(工作区列表、账号设置)就长什么样。
@@ -64,6 +64,16 @@ export function SideRail({ activeId }: { activeId?: string | null }) {
<Settings size={14} className="text-ink-400" />
账号设置
</Link>
{/* 系统设置是平台级配置(全站共用一份),只给平台管理员看 */}
{user?.platform_admin ? (
<Link
to="/settings"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-ink-500 transition hover:bg-paper-100 hover:text-ink-800"
>
<Sliders size={14} className="text-ink-400" />
系统设置
</Link>
) : null}
</nav>
<div className="border-t border-line-200 px-3 py-3">
@@ -94,6 +104,15 @@ export function SideRail({ activeId }: { activeId?: string | null }) {
<span className="text-sm font-semibold text-ink-900">达道Task</span>
</Link>
<div className="flex items-center gap-1">
{user?.platform_admin ? (
<Link
to="/settings"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="系统设置"
>
<Sliders size={16} />
</Link>
) : null}
<Link
to="/account"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
+8
View File
@@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => (
</svg>
);
export const Sliders = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
<circle cx="15" cy="7.5" r="2" />
<circle cx="9" cy="16.5" r="2" />
</svg>
);
export const User = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="8.5" r="3.5" />
+1
View File
@@ -125,6 +125,7 @@ export const api = {
get: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
put: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PUT", body }),
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
};
+20
View File
@@ -1,13 +1,33 @@
export interface User {
id: string;
phone: string;
/** 邮箱是账号名(可空):能用它 + 密码登录,也是以后找回密码的去处 */
email?: string | null;
nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
/** 平台管理员:能进「系统设置」改 SMTP 这类跨租户的配置 */
platform_admin?: boolean;
created_at?: string;
}
/**
* 发信配置的回执:**永远不含授权码**,只说"设没设"。
* `source` 说明当前值是从数据库读的还是从服务器环境变量兜的。
*/
export interface SmtpSettings {
host: string | null;
port: number;
username: string | null;
password_set: boolean;
from_name: string;
use_ssl: boolean;
configured: boolean;
source: "db" | "env" | "none";
updated_at?: string | null;
}
export interface Workspace {
id: string;
name: string;
+53 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { SideRail } from "../components/SideRail";
import { Button, Field, InlineAlert } from "../components/ui";
@@ -7,10 +7,10 @@ import { ApiError, api } from "../lib/client";
import type { User } from "../lib/types";
/**
* 账号设置:昵称、登录密码、退出登录。
* 账号设置:昵称、邮箱、登录密码、退出登录。
*
* 密码是**可选**的第二条登录路径:没设过的人一直走验证码,
* 界面上的文案要能让人看懂"现在设的是第一道密码"还是"在改已有的密码"。
* 邮箱是**账号名**(能拿它 + 密码登录),不是"通知地址"的附属品,
* 所以放在基本信息里、和手机号并列,而不是藏在通知偏好里。
*/
/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */
function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) {
@@ -27,6 +27,7 @@ export function AccountPage() {
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
const [email, setEmail] = useState("");
const [profileBusy, setProfileBusy] = useState(false);
const [profileMsg, setProfileMsg] = useState<string | null>(null);
const [profileError, setProfileError] = useState<string | null>(null);
@@ -44,9 +45,12 @@ export function AccountPage() {
useEffect(() => {
if (user?.nickname) setNickname(user.nickname);
}, [user?.nickname]);
setEmail(user?.email ?? "");
}, [user?.nickname, user?.email]);
const hasPassword = Boolean(user?.has_password);
const nicknameChanged = nickname.trim() !== (user?.nickname ?? "");
const emailChanged = email.trim() !== (user?.email ?? "");
async function saveProfile(event: React.FormEvent) {
event.preventDefault();
@@ -54,9 +58,18 @@ export function AccountPage() {
setProfileMsg(null);
setProfileError(null);
try {
await api.patch<User>("/auth/me", { nickname: nickname.trim() });
// 只发改过的字段:邮箱留空表示"解绑",不能因为没动它就被清掉
const patch: { nickname?: string; email?: string } = {};
if (nicknameChanged) patch.nickname = nickname.trim();
if (emailChanged) patch.email = email.trim();
await api.patch<User>("/auth/me", patch);
await reload();
setProfileMsg("昵称已更新");
setProfileMsg(
emailChanged && !email.trim()
? "邮箱已解绑,之后不能用邮箱登录"
: "资料已更新",
);
} catch (err) {
setProfileError(err instanceof ApiError ? err.message : "保存失败");
} finally {
@@ -146,6 +159,21 @@ export function AccountPage() {
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
</p>
<Field label="邮箱">
<input
className="input"
type="email"
placeholder="绑定后可以用邮箱 + 密码登录"
value={email}
maxLength={255}
aria-label="邮箱"
onChange={(e) => setEmail(e.target.value.trim())}
/>
</Field>
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
邮箱是第二个账号名:绑上之后,登录页可以用「邮箱 + 密码」进入。留空保存就是解绑。
</p>
{profileMsg && <InlineAlert tone="success">{profileMsg}</InlineAlert>}
{profileError && <InlineAlert>{profileError}</InlineAlert>}
@@ -153,9 +181,9 @@ export function AccountPage() {
type="submit"
variant="primary"
size="sm"
disabled={profileBusy || !nickname.trim() || nickname.trim() === user?.nickname}
disabled={profileBusy || !nickname.trim() || (!nicknameChanged && !emailChanged)}
>
{profileBusy ? "保存中…" : "保存昵称"}
{profileBusy ? "保存中…" : "保存资料"}
</Button>
</form>
</section>
@@ -235,6 +263,22 @@ export function AccountPage() {
退出登录
</Button>
</section>
{user?.platform_admin ? (
<section className="card p-5">
<SectionLabel hint="平台级配置,只有平台管理员看得到">系统设置</SectionLabel>
<p className="mt-3 text-xs leading-relaxed text-ink-500">
发信邮箱(SMTP)在那儿改:填完可以当场发一封测试邮件,确认通道通不通。
改完即时生效,不用登服务器改配置、也不用重启。
</p>
<Link
to="/settings"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary-700 hover:underline"
>
打开系统设置 →
</Link>
</section>
) : null}
</div>
</div>
</div>
+89 -21
View File
@@ -30,33 +30,61 @@ const HIGHLIGHTS = [
},
];
const PHONE_RE = /^1[3-9]\d{9}$/;
const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/;
/** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */
type Methods = { password: boolean; email_password: boolean; sms_code: boolean };
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const next = safeNext(params.get("next"));
const [phone, setPhone] = useState("");
const [account, setAccount] = useState("");
const [accountType, setAccountType] = useState<"phone" | "email">("phone");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("code");
const [mode, setMode] = useState<"code" | "password">("password");
const [methods, setMethods] = useState<Methods>({ password: true, email_password: true, sms_code: false });
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
let alive = true;
api
.get<Methods>("/auth/methods")
.then((data) => {
if (!alive) return;
setMethods(data);
// 短信关着就别停在"验证码"这一档
setMode(data.sms_code ? "password" : "password");
})
.catch(() => {
/* 拿不到就按"只有密码"渲染,至少不会把人挡在门外 */
});
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
const phoneValid = PHONE_RE.test(account);
const emailValid = EMAIL_RE.test(account.trim());
const accountValid = accountType === "phone" ? phoneValid : emailValid;
async function sendCode() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone });
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone: account });
setDevCode(data.dev_code ?? null);
setCountdown(60);
} catch (err) {
@@ -73,9 +101,9 @@ export function LoginPage() {
try {
const data =
mode === "code"
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone: account, code })
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
phone,
...(accountType === "phone" ? { phone: account } : { email: account.trim() }),
password,
});
session.save(data.tokens);
@@ -88,6 +116,13 @@ export function LoginPage() {
}
}
const subtitle =
mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: methods.sms_code
? "手机号或邮箱 + 密码。还没设过密码的,先用验证码登录一次再设。"
: "用手机号或邮箱 + 密码登录。";
return (
<div className="flex min-h-full flex-col lg:flex-row">
{/* 左:品牌页 */}
@@ -143,17 +178,15 @@ export function LoginPage() {
</div>
<h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">
{mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
</p>
<p className="mt-1.5 text-sm text-ink-500">{subtitle}</p>
{/* 只有服务端真开着验证码,才给这两个档位;否则只留密码表单 */}
{methods.sms_code && (
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
{(
[
["code", "验证码"],
["password", "密码"],
["code", "验证码"],
] as const
).map(([key, label]) => (
<button
@@ -171,18 +204,53 @@ export function LoginPage() {
</button>
))}
</div>
)}
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="手机号">
{/* 验证码只能发到手机号,所以这一档不给"邮箱"选项 */}
{mode === "password" && (
<div className="inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs" role="tablist" aria-label="账号类型">
{(
[
["phone", "手机号"],
["email", "邮箱"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={accountType === key}
onClick={() => {
setAccountType(key);
setAccount("");
setError(null);
}}
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
accountType === key
? "bg-white text-ink-900 shadow-sm"
: "text-ink-500 hover:text-ink-700"
}`}
>
{label}
</button>
))}
</div>
)}
<Field label={accountType === "phone" || mode === "code" ? "手机号" : "邮箱"}>
<input
id="phone"
id="account"
className="input h-11"
inputMode="numeric"
autoComplete="tel"
placeholder="13800138000"
value={phone}
onChange={(e) => setPhone(e.target.value.trim())}
maxLength={11}
type={accountType === "phone" || mode === "code" ? "tel" : "email"}
inputMode={accountType === "phone" || mode === "code" ? "numeric" : "email"}
autoComplete={accountType === "phone" || mode === "code" ? "tel" : "email"}
placeholder={
accountType === "phone" || mode === "code" ? "13800138000" : "you@example.com"
}
value={account}
onChange={(e) => setAccount(e.target.value.trim())}
maxLength={accountType === "phone" || mode === "code" ? 11 : 255}
/>
</Field>
@@ -238,7 +306,7 @@ export function LoginPage() {
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
disabled={busy || !accountValid || (mode === "code" ? code.length < 4 : password.length < 1)}
>
{busy ? "处理中…" : "登录"}
</Button>
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { SideRail } from "../components/SideRail";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { SmtpSettings } from "../lib/types";
/**
* 系统设置:平台级配置,全站共用一份,只有平台管理员能进。
*
* 目前只有「发信邮箱」一项——它承载的是找回密码、以后的通知邮件,
* 属于"改一次、全站受影响"的东西,所以放后台可改,而不是写死在服务器 env 里。
*
* 授权码只写不读:接口永远不会把它发回来,页面上也只显示"已设置 / 未设置"。
*/
function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) {
return (
<div>
<h2 className="text-sm font-medium text-ink-800">{children}</h2>
{hint ? <p className="mt-0.5 text-2xs text-ink-400">{hint}</p> : null}
</div>
);
}
const SOURCE_HINT: Record<SmtpSettings["source"], string> = {
db: "正在用后台保存的配置",
env: "后台还没保存过,先用的是服务器环境变量",
none: "还没配过,发信功能不可用",
};
export function SettingsPage() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [ready, setReady] = useState(false);
const [host, setHost] = useState("");
const [port, setPort] = useState("465");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [fromName, setFromName] = useState("达道Task");
const [useSsl, setUseSsl] = useState(true);
const [saved, setSaved] = useState<SmtpSettings | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testTo, setTestTo] = useState("");
const [testing, setTesting] = useState(false);
useEffect(() => {
if (!loading && !user) navigate("/login", { replace: true });
}, [loading, user, navigate]);
useEffect(() => {
// 非平台管理员不该看到这个页面,也别去请求(后端会 3004)
if (loading || !user || !user.platform_admin) return;
let alive = true;
(async () => {
try {
const data = await api.get<SmtpSettings>("/settings/smtp");
if (!alive) return;
setSaved(data);
setHost(data.host ?? "");
setPort(String(data.port ?? 465));
setUsername(data.username ?? "");
setFromName(data.from_name ?? "达道Task");
setUseSsl(data.use_ssl);
setTestTo(data.username ?? "");
} catch (err) {
if (alive) setError(err instanceof ApiError ? err.message : "读取配置失败");
} finally {
if (alive) setReady(true);
}
})();
return () => {
alive = false;
};
}, [loading, user]);
async function save(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setMsg(null);
setError(null);
try {
const data = await api.put<SmtpSettings>("/settings/smtp", {
host: host.trim(),
port: Number(port) || 465,
username: username.trim(),
// 留空 = 不改授权码;接口把空串当"沿用旧值"
password: password.trim() || null,
from_name: fromName.trim() || "达道Task",
use_ssl: useSsl,
});
setSaved(data);
setPassword("");
setMsg(data.configured ? "已保存,现在就能发信了" : "已保存,但还差必填项");
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setBusy(false);
}
}
async function sendTest() {
setTesting(true);
setMsg(null);
setError(null);
try {
const data = await api.post<{ to: string; message?: string }>("/settings/smtp/test", {
to: testTo.trim() || null,
});
setMsg(`测试邮件已发到 ${data.to},去收件箱看一眼(可能在垃圾箱)`);
} catch (err) {
setError(err instanceof ApiError ? err.message : "发信失败");
} finally {
setTesting(false);
}
}
if (loading || !ready) {
return (
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<SideRail />
<main className="min-w-0 flex-1 p-8 text-sm text-ink-400">读取配置…</main>
</div>
);
}
if (!user?.platform_admin) {
return (
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<SideRail />
<main className="min-w-0 flex-1">
<div className="w-full px-5 py-8 lg:px-8 xl:px-12">
<div className="card p-6 text-sm text-ink-600">
系统设置只有平台管理员能改。你在自己的账号页能改昵称、邮箱和密码。
</div>
</div>
</main>
</div>
);
}
return (
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<SideRail />
<main className="min-w-0 flex-1">
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
<div className="mb-5">
<h1 className="text-lg font-semibold tracking-tight text-ink-900">系统设置</h1>
<p className="mt-1 text-xs text-ink-500">
这里的改动全站生效,改完不用重启服务。只有平台管理员能进来。
</p>
</div>
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<section className="card p-5">
<SectionLabel hint="126/163 这类邮箱要用「客户端授权码」,不是登录密码">
发信邮箱(SMTP)
</SectionLabel>
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={save}>
<Field label="服务器地址">
<input
className="input"
placeholder="smtp.126.com"
value={host}
aria-label="服务器地址"
onChange={(e) => setHost(e.target.value.trim())}
/>
</Field>
<Field label="端口">
<input
className="input"
inputMode="numeric"
value={port}
aria-label="端口"
onChange={(e) => setPort(e.target.value.replace(/[^\d]/g, ""))}
/>
</Field>
<Field label="账号">
<input
className="input"
placeholder="shufenhf@126.com"
value={username}
aria-label="账号"
onChange={(e) => setUsername(e.target.value.trim())}
/>
</Field>
<Field label={saved?.password_set ? "授权码(留空表示不改)" : "授权码"}>
<input
className="input"
type="password"
placeholder={saved?.password_set ? "已设置,留空保持原样" : "客户端授权码"}
value={password}
autoComplete="new-password"
aria-label="授权码"
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Field label="发件人显示名">
<input
className="input"
value={fromName}
aria-label="发件人显示名"
onChange={(e) => setFromName(e.target.value)}
/>
</Field>
<label className="flex items-end gap-2 pb-2 text-xs text-ink-600">
<input
type="checkbox"
checked={useSsl}
aria-label="使用 SSL"
onChange={(e) => setUseSsl(e.target.checked)}
/>
使用 SSL(465 端口要勾,587 一般走 STARTTLS)
</label>
<div className="sm:col-span-2">
{msg && <InlineAlert tone="success">{msg}</InlineAlert>}
{error && <InlineAlert>{error}</InlineAlert>}
<Button type="submit" variant="primary" size="sm" disabled={busy}>
{busy ? "保存中…" : "保存"}
</Button>
</div>
</form>
</section>
<div className="space-y-4">
<section className="card p-5">
<SectionLabel hint={SOURCE_HINT[saved?.source ?? "none"]}>当前状态</SectionLabel>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex items-center justify-between gap-3">
<dt className="text-ink-400">能不能发信</dt>
<dd className={saved?.configured ? "font-medium text-primary-700" : "text-clay-600"}>
{saved?.configured ? "可以" : "还不能"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-ink-400">授权码</dt>
<dd className="text-ink-600">{saved?.password_set ? "已设置" : "未设置"}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-ink-400">上次修改</dt>
<dd className="text-ink-600">
{saved?.updated_at
? new Date(saved.updated_at).toLocaleString("zh-CN")
: "—"}
</dd>
</div>
</dl>
</section>
<section className="card p-5">
<SectionLabel hint="别等真要用的时候才发现发不出去">发一封测试邮件</SectionLabel>
<div className="mt-3 space-y-3">
<Field label="收件人">
<input
className="input"
placeholder="留空就发给当前账号绑的邮箱"
value={testTo}
aria-label="测试收件人"
onChange={(e) => setTestTo(e.target.value.trim())}
/>
</Field>
<Button variant="ghost" size="sm" onClick={sendTest} disabled={testing}>
{testing ? "发送中…" : "发送测试邮件"}
</Button>
</div>
</section>
</div>
</div>
</div>
</main>
</div>
);
}