diff --git a/2.Design/API.md b/2.Design/API.md index e94350c..1f8cc2d 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -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`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的 +**客户端授权码**,不是登录密码。 --- diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 93d1d97..e38e002 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -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()` diff --git a/2.Design/screens/web-account-email.png b/2.Design/screens/web-account-email.png new file mode 100644 index 0000000..16c7bdd Binary files /dev/null and b/2.Design/screens/web-account-email.png differ diff --git a/2.Design/screens/web-login-password.png b/2.Design/screens/web-login-password.png new file mode 100644 index 0000000..23a0b5f Binary files /dev/null and b/2.Design/screens/web-login-password.png differ diff --git a/2.Design/screens/web-settings-smtp.png b/2.Design/screens/web-settings-smtp.png new file mode 100644 index 0000000..f3a76e6 Binary files /dev/null and b/2.Design/screens/web-settings-smtp.png differ diff --git a/backend/alembic/versions/0010_email_and_settings.py b/backend/alembic/versions/0010_email_and_settings.py new file mode 100644 index 0000000..a310c69 --- /dev/null +++ b/backend/alembic/versions/0010_email_and_settings.py @@ -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") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a176e35..dadda16 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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)}) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..a5624e9 --- /dev/null +++ b/backend/app/api/settings.py @@ -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}) diff --git a/backend/app/config.py b/backend/app/config.py index 8bf24f3..231c99d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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)) diff --git a/backend/app/deps.py b/backend/app/deps.py index a8650ac..987a672 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -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)] diff --git a/backend/app/errors.py b/backend/app/errors.py index 4b320fd..9ca9538 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -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, diff --git a/backend/app/main.py b/backend/app/main.py index 8a75606..9c66de7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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="存活探针") diff --git a/backend/app/models.py b/backend/app/models.py index 0906a54..7b3d6eb 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/services/mail.py b/backend/app/services/mail.py new file mode 100644 index 0000000..1ce8703 --- /dev/null +++ b/backend/app/services/mail.py @@ -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, + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 99f9c33..f7fb496 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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: diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py index 4878401..83dabb2 100644 --- a/backend/tests/test_account.py +++ b/backend/tests/test_account.py @@ -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", } diff --git a/backend/tests/test_config_prod.py b/backend/tests/test_config_prod.py index 11ce030..554383b 100644 --- a/backend/tests/test_config_prod.py +++ b/backend/tests/test_config_prod.py @@ -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: diff --git a/backend/tests/test_login_email.py b/backend/tests/test_login_email.py new file mode 100644 index 0000000..8f42eaa --- /dev/null +++ b/backend/tests/test_login_email.py @@ -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"] diff --git a/backend/tests/test_settings_smtp.py b/backend/tests/test_settings_smtp.py new file mode 100644 index 0000000..1ea6432 --- /dev/null +++ b/backend/tests/test_settings_smtp.py @@ -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" diff --git a/web/e2e/login-email-smtp.mjs b/web/e2e/login-email-smtp.mjs new file mode 100644 index 0000000..8117698 --- /dev/null +++ b/web/e2e/login-email-smtp.mjs @@ -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); +}); diff --git a/web/e2e/shots-account-login.mjs b/web/e2e/shots-account-login.mjs new file mode 100644 index 0000000..00e84db --- /dev/null +++ b/web/e2e/shots-account-login.mjs @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 284b61c..42971ee 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/SideRail.tsx b/web/src/components/SideRail.tsx index 2e5021d..5adae71 100644 --- a/web/src/components/SideRail.tsx +++ b/web/src/components/SideRail.tsx @@ -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 }) { 账号设置 + {/* 系统设置是平台级配置(全站共用一份),只给平台管理员看 */} + {user?.platform_admin ? ( + + + 系统设置 + + ) : null}
@@ -94,6 +104,15 @@ export function SideRail({ activeId }: { activeId?: string | null }) { 达道Task
+ {user?.platform_admin ? ( + + + + ) : null} ( ); +export const Sliders = (p: IconProps) => ( + + + + + +); + export const User = (p: IconProps) => ( diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index 51f6d30..8c23120 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -125,6 +125,7 @@ export const api = { get: (path: string, signal?: AbortSignal) => raw(path, { signal }), post: (path: string, body?: unknown) => raw(path, { method: "POST", body }), patch: (path: string, body?: unknown) => raw(path, { method: "PATCH", body }), + put: (path: string, body?: unknown) => raw(path, { method: "PUT", body }), del: (path: string) => raw(path, { method: "DELETE" }), }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0bbaab6..bc056ea 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx index 693ec15..c4833fb 100644 --- a/web/src/pages/Account.tsx +++ b/web/src/pages/Account.tsx @@ -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(null); const [profileError, setProfileError] = useState(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("/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("/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() { 手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。

+ + +

+ 邮箱是第二个账号名:绑上之后,登录页可以用「邮箱 + 密码」进入。留空保存就是解绑。 +

+ {profileMsg && {profileMsg}} {profileError && {profileError}} @@ -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 ? "保存中…" : "保存资料"} @@ -235,6 +263,22 @@ export function AccountPage() { 退出登录 + + {user?.platform_admin ? ( +
+ 系统设置 +

+ 发信邮箱(SMTP)在那儿改:填完可以当场发一封测试邮件,确认通道通不通。 + 改完即时生效,不用登服务器改配置、也不用重启。 +

+ + 打开系统设置 → + +
+ ) : null}
diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 2587937..26b7bc4 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -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({ password: true, email_password: true, sms_code: false }); const [devCode, setDevCode] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [countdown, setCountdown] = useState(0); + useEffect(() => { + let alive = true; + api + .get("/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 (
{/* 左:品牌页 */} @@ -143,46 +178,79 @@ export function LoginPage() {

登录

-

- {mode === "code" - ? "手机号验证码登录,第一次登录会自动创建账号。" - : "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"} -

+

{subtitle}

-
- {( - [ - ["code", "验证码"], - ["password", "密码"], - ] as const - ).map(([key, label]) => ( - - ))} -
+ {/* 只有服务端真开着验证码,才给这两个档位;否则只留密码表单 */} + {methods.sms_code && ( +
+ {( + [ + ["password", "密码"], + ["code", "验证码"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ )}
- + {/* 验证码只能发到手机号,所以这一档不给"邮箱"选项 */} + {mode === "password" && ( +
+ {( + [ + ["phone", "手机号"], + ["email", "邮箱"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ )} + + 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} /> @@ -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 ? "处理中…" : "登录"} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..feee328 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -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 ( +
+

{children}

+ {hint ?

{hint}

: null} +
+ ); +} + +const SOURCE_HINT: Record = { + 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(null); + + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(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("/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("/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 ( +
+ +
读取配置…
+
+ ); + } + + if (!user?.platform_admin) { + return ( +
+ +
+
+
+ 系统设置只有平台管理员能改。你在自己的账号页能改昵称、邮箱和密码。 +
+
+
+
+ ); + } + + return ( +
+ + +
+
+
+

系统设置

+

+ 这里的改动全站生效,改完不用重启服务。只有平台管理员能进来。 +

+
+ +
+
+ + 发信邮箱(SMTP) + + + + + setHost(e.target.value.trim())} + /> + + + + setPort(e.target.value.replace(/[^\d]/g, ""))} + /> + + + + setUsername(e.target.value.trim())} + /> + + + + setPassword(e.target.value)} + /> + + + + setFromName(e.target.value)} + /> + + + + +
+ {msg && {msg}} + {error && {error}} + +
+ +
+ +
+
+ 当前状态 +
+
+
能不能发信
+
+ {saved?.configured ? "可以" : "还不能"} +
+
+
+
授权码
+
{saved?.password_set ? "已设置" : "未设置"}
+
+
+
上次修改
+
+ {saved?.updated_at + ? new Date(saved.updated_at).toLocaleString("zh-CN") + : "—"} +
+
+
+
+ +
+ 发一封测试邮件 +
+ + setTestTo(e.target.value.trim())} + /> + + +
+
+
+
+
+
+
+ ); +}