Compare commits

..
2 Commits
Author SHA1 Message Date
Steven 096acb00e7 feat(web): 首页换成和工作区同一套外壳,补上账号设置页
CI / backend (push) Canceled after 0s
首页原来是居中窄栏,点进工作区又变全屏,进出像换了个网站。现在把首页也做成
全宽 + 左侧栏(SideRail 抽出来给首页和账号页共用),工作区卡片改成网格,
新建工作区是网格里的虚线卡片——宽度不再跳。

账号页 /account:基本信息(头像首字母、昵称、只读手机号、加入时间)、
登录密码(设置/修改 + 确认输入)、退出登录。工作区侧栏底部的用户块点进去就是它。

e2e account.mjs 14 项全绿,其中一条断言进工作区后侧栏宽度和首页差值 < 1px。
2026-10-31 04:05:05 +08:00
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

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

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00
20 changed files with 1343 additions and 178 deletions
+34
View File
@@ -29,6 +29,10 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 | | 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 | | 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 | | 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 3001 | 404 | 工作区不存在 | — | | 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -91,9 +95,39 @@
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。 返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
### `POST /auth/login/password`
```json
{ "phone": "13800000000", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
### `POST /auth/password`
```json
{ "password": "dadao2026", "old_password": "old-one-1" }
```
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
### `PATCH /auth/me`
```json
{ "nickname": "大道" }
```
返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me` ### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
--- ---
+47 -1
View File
@@ -58,7 +58,8 @@
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`) - [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高) - [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0) - [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行) - [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`)
- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行)
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销) - [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰 - **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
@@ -450,3 +451,48 @@
- [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false` - [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false`
- [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传 - [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传
- [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png` - [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png`
---
## 7. 账号与外壳统一(Web,2026-10-31)
### 7.1 两个问题(用户提的)
1. 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。
2. 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。
### 7.2 决定
- **外壳统一到全宽 + 左侧栏**(不是把工作区收回窄栏):工作区里本来就要放下看板四列、知识分类树,
收窄是倒退。首页跟着做同样的壳,宽度就不再跳。
- **加密码登录,作为验证码之外的第二条路**。理由:验证码依赖短信通道,通道挂了人就进不来;
密码是自助的第二把钥匙。但**不强制**:没设密码的人照旧只用验证码,少一道门槛。
- 外壳抽成 `web/src/components/SideRail.tsx`,首页与账号页共用;工作区的 `AppShell` 保持自己的侧栏(有工作区切换与页签)。
两处宽度一致(`w-[248px]` 级别的同一份样式),所以进出不跳。
### 7.3 后端
- `app_user.password_hash`(迁移 `0008_user_password`,加列不加表)
- `app/security.py`:scrypt(`n=2^14, r=8, p=1`,标准库 `hashlib`),存 `scrypt$n$r$p$salt$hash`;
`password_problem()` 一处定义强度(8–64 位、字母 + 数字)
- `POST /auth/login/password`、`POST /auth/password`(设/改共用,改要先验旧)、`PATCH /auth/me`(改昵称)
- 用户输出统一走 `_user_out()`:**白名单字段**,`password_hash` 一个字节都不出去;对外只给 `has_password`
- 错误码:`2013` 密码错/没设过、`2014` 未设密码(保留)、`2015` 太弱、`2016` 连错太多
### 7.4 Web
- 首页:左侧栏 + 全宽内容区,工作区从列表改成卡片网格(`sm:2 / xl:3 / 2xl:4` 列),
"新建工作区"是网格里的一张虚线卡片,点开就地展开表单
- 新增 `/account`:基本信息(头像首字母、昵称、只读手机号、加入时间)、登录密码(设置/修改 + 确认输入)、退出登录
- 登录页:顶部「验证码 / 密码」两段切换,同一个表单两种提交
- 工作区 `AppShell` 底部用户块可点进 `/account`,旁边一个齿轮图标
### 7.5 验证
- 后端 `pytest` → **113 passed**(新增 `tests/test_account.py` 10 项);`ruff check` / `ruff format --check` 全绿
- Web `tsc --noEmit` 通过、`vite build` 通过
- 新增 e2e `web/e2e/account.mjs`(**13 项全绿**):登录进首页 → 首页有侧栏 → 进工作区**侧栏宽度不变(差值 < 1px)**
→ 账号页复用同一条侧栏 → 改昵称 → 设/改密码 → 刷新后仍显示"已设置密码" → 退出 →
错密码被拒(`2013`)→ 密码登录成功 → `/auth/me` 里 `has_password=true` 且昵称是刚改的 → 验证码登录仍可用 → 控制台零报错
- 截图:`2.Design/screens/web-home-rail.png`、`web-account-settings.png`
- 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录);
② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护
### 7.6 没做的
- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出)
- 手机号换绑、头像上传未做(头像现在只有首字母)
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,40 @@
"""登录密码(M1 账号功能)
Revision ID: 0008
Revises: 0007
Create Date: 2026-10-31
M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、
或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。
两个取舍:
1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程;
登录页两种方式并存,谁设过谁自己知道。
2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。
密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0008"
down_revision: str = "0007"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE app_user
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
"""
)
def downgrade() -> None:
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash")
+132 -31
View File
@@ -1,4 +1,8 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。""" """认证:手机号 + 验证码,可选登录密码。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
"""
from __future__ import annotations from __future__ import annotations
@@ -14,7 +18,13 @@ from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace from ..models import AppUser, Member, Workspace
from ..schemas import ok from ..schemas import ok
from ..security import create_token_pair, decode_token from ..security import (
create_token_pair,
decode_token,
hash_password,
password_problem,
verify_password,
)
from ..stores import get_store, sms_code_key, sms_throttle_key from ..stores import get_store, sms_code_key, sms_throttle_key
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
@@ -22,11 +32,36 @@ settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$" PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str: def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"created_at": user.created_at,
}
def _tokens_out(tokens) -> dict:
return {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
class SendCodeIn(BaseModel): class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"]) phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
@@ -36,6 +71,22 @@ class LoginIn(BaseModel):
code: str = Field(min_length=4, max_length=8) code: str = Field(min_length=4, max_length=8)
class PasswordLoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
password: str = Field(min_length=1, max_length=64)
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
class PasswordIn(BaseModel):
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
password: str = Field(min_length=1, max_length=64)
old_password: str | None = Field(default=None, max_length=64)
class RefreshIn(BaseModel): class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10) refresh_token: str = Field(min_length=10)
@@ -81,40 +132,54 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
await session.flush() await session.flush()
tokens = create_token_pair(user.id) tokens = create_token_pair(user.id)
return ok( return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
{
"user": {
"id": str(user.id), @router.post("/login/password", summary="密码登录")
"phone": mask_phone(user.phone), async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"nickname": user.nickname, """手机号 + 密码登录。
"created_at": user.created_at,
}, 两个细节:
"tokens": { - **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
"access_token": tokens.access_token, - 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
"refresh_token": tokens.refresh_token, """
"token_type": "bearer", store = get_store()
"expires_in": tokens.expires_in, fail_key = f"pwd_fail:{body.phone}"
}, if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
} raise AppError(
) ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
)
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对")
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
await store.delete(fail_key)
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/refresh", summary="刷新访问令牌") @router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict: async def refresh(body: RefreshIn) -> dict:
user_id = decode_token(body.refresh_token, "refresh") user_id = decode_token(body.refresh_token, "refresh")
tokens = create_token_pair(user_id) tokens = create_token_pair(user_id)
return ok( # 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
{ return ok(_tokens_out(tokens))
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
)
@router.get("/me", summary="当前用户与所属工作区") @router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict: async def me(user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
rows = ( rows = (
await session.execute( await session.execute(
select(Workspace, Member.role) select(Workspace, Member.role)
@@ -126,11 +191,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
return ok( return ok(
{ {
"user": { "user": _user_out(row),
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
},
"workspaces": [ "workspaces": [
{ {
"id": str(ws.id), "id": str(ws.id),
@@ -144,3 +205,43 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
], ],
} }
) )
@router.patch("/me", summary="改自己的昵称")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if body.nickname is not None:
nickname = body.nickname.strip()
if not nickname:
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
await session.flush()
return ok({"user": _user_out(row)})
@router.post("/password", summary="设置或修改登录密码")
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if row.password_hash:
if not body.old_password:
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
if not verify_password(body.old_password, row.password_hash):
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
if hmac.compare_digest(body.old_password, body.password):
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
row.password_hash = hash_password(body.password)
await session.flush()
return ok({"has_password": True})
+8
View File
@@ -25,6 +25,10 @@ class ErrorCode(IntEnum):
SMS_CODE_INVALID = 2010 SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011 SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012 ACCOUNT_DISABLED = 2012
PASSWORD_INVALID = 2013
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
# 3xxx 租户 / 权限 # 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001 WORKSPACE_NOT_FOUND = 3001
@@ -86,6 +90,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.SMS_CODE_INVALID: 400, ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429, ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403, ErrorCode.ACCOUNT_DISABLED: 403,
ErrorCode.PASSWORD_INVALID: 400,
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.WORKSPACE_NOT_FOUND: 404, ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403, ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403, ErrorCode.INSUFFICIENT_ROLE: 403,
+2
View File
@@ -45,6 +45,8 @@ class AppUser(Base, Timestamps):
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512)) avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
class Workspace(Base, Timestamps): class Workspace(Base, Timestamps):
+63
View File
@@ -8,6 +8,8 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import hmac import hmac
import secrets import secrets
import time import time
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
def invite_code_matches(provided: str, expected: str) -> bool: def invite_code_matches(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.strip().upper(), expected) return hmac.compare_digest(provided.strip().upper(), expected)
# —— 登录密码 ——
# 用标准库 scrypt,不引入 passlib/bcrypt:少一个依赖,也就少一处装不上的风险。
# 存储格式 `scrypt$n$r$p$salt_b64$hash_b64`,参数写进串里,将来调强度不用改代码。
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_PASSWORD_MIN = 8
_PASSWORD_MAX = 64
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
)
return "$".join(
[
"scrypt",
str(_SCRYPT_N),
str(_SCRYPT_R),
str(_SCRYPT_P),
base64.b64encode(salt).decode(),
base64.b64encode(digest).decode(),
]
)
def verify_password(password: str, stored: str | None) -> bool:
"""stored 为空(没设过密码)一律返回 False,别把"没密码"当成"密码对"。"""
if not stored:
return False
try:
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode("utf-8"),
salt=base64.b64decode(salt_b64),
n=int(n),
r=int(r),
p=int(p),
dklen=len(base64.b64decode(hash_b64)),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(digest, base64.b64decode(hash_b64))
def password_problem(password: str) -> str | None:
"""返回不合格的原因;合格返回 None。规则写在一处,设置与修改都用它。"""
if len(password) < _PASSWORD_MIN:
return f"密码至少 {_PASSWORD_MIN} 位"
if len(password) > _PASSWORD_MAX:
return f"密码最多 {_PASSWORD_MAX} 位"
has_letter = any(c.isalpha() for c in password)
has_digit = any(c.isdigit() for c in password)
if not (has_letter and has_digit):
return "密码要同时有字母和数字"
return None
+187
View File
@@ -0,0 +1,187 @@
"""账号功能:昵称、密码设置/修改、密码登录。
这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看:
- 密码一字节都不回给客户端(只回 has_password)
- 没设过密码的人不会被密码登录误放进去
- 改完密码,旧密码立刻失效
- 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜
"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import auth, login, unwrap
async def _me(client: AsyncClient, user: dict) -> dict:
return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None:
user = await login(client)
data = await _me(client, user)
assert data["user"]["has_password"] is False
assert data["user"]["phone"].endswith(user["phone"][-4:])
assert "****" in data["user"]["phone"]
# 只回「设没设」,不回密码本身:字段白名单比黑名单可靠
assert set(data["user"]) == {
"id",
"phone",
"nickname",
"avatar_url",
"has_password",
"created_at",
}
async def test_patch_nickname(client: AsyncClient) -> None:
user = await login(client)
data = unwrap(
await client.patch(
"/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"]
)
)
assert data["user"]["nickname"] == "老王"
# 空昵称要被拦住,不能变成"无名氏"
resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"])
assert resp.json()["code"] == 1002
async def test_set_password_then_login_with_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2026"},
headers=user["headers"],
)
)
assert (await _me(client, user))["user"]["has_password"] is True
# 验证码登录照旧可用,两条路并存
data = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "dadao2026"},
)
)
assert data["tokens"]["access_token"]
assert data["user"]["id"] == user["user_id"]
async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"}
)
assert resp.status_code == 400
assert resp.json()["code"] == 2013
async def test_password_login_on_account_without_password(client: AsyncClient) -> None:
user = await login(client)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去
assert resp.json()["code"] == 2013
async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
# 不给旧密码 → 拒绝
resp = await client.post(
"/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"]
)
assert resp.json()["code"] == 2013
# 旧密码给错 → 拒绝
resp = await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "nope-1234"},
headers=user["headers"],
)
assert resp.json()["code"] == 2013
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "dadao2026"},
headers=user["headers"],
)
)
# 新密码能登,旧密码不能
unwrap(
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"}
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_weak_password_rejected(client: AsyncClient) -> None:
user = await login(client)
for weak in ["short1", "12345678", "abcdefgh"]:
resp = await client.post(
"/api/v1/auth/password", json={"password": weak}, headers=user["headers"]
)
assert resp.json()["code"] == 2015, weak
async def test_password_endpoints_require_login(client: AsyncClient) -> None:
resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"})
assert resp.status_code == 401
resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope"))
assert resp.status_code == 401
async def test_repeated_failures_are_throttled(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
for _ in range(10):
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"}
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求
assert resp.json()["code"] == 2016
+220
View File
@@ -0,0 +1,220 @@
/**
* 账号功能的验收脚本:首页外壳、账号设置、密码登录。
*
* 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么",
* 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。
*
* 两个坑记在这里:
* 1. 短信验证码有 60 秒节流,反复跑脚本会拿到 429,于是"发码"这一步要先看
* 是不是被拦了;登录态优先用密码直接从接口换,别每次都走验证码。
* 2. 改密码要先验证旧密码,而系统不会告诉你旧密码——上一轮的密码记在
* .local/e2e-account-password.txt 里,脚本自己维护。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync, writeFileSync } 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 CODE = process.env.CODE ?? "123456";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
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;
}
}
function randomPassword() {
return `dadao${Math.floor(Math.random() * 900000 + 100000)}`;
}
async function apiLogin(password) {
if (!password) return null;
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password }),
});
const body = await r.json();
return body?.code === 0 ? body.data.tokens : null;
}
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)));
// ① 先拿到登录态:有密码就直接换令牌,省掉一次发码
let tokens = await apiLogin(savedPassword());
if (tokens) {
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[tokens.access_token, tokens.refresh_token],
);
}
await page.goto(WEB);
if (!tokens) {
// 第一次跑(还没有密码)只能走验证码;被节流就等一会儿再发
await page.goto(`${WEB}/login`);
await page.getByLabel("手机号").fill(PHONE);
for (let i = 0; i < 2; i += 1) {
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForTimeout(1500);
const throttled = await page.locator("text=/秒后再试|稍后/").count();
if (!throttled) break;
await page.waitForTimeout(61_000);
}
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForURL(`${WEB}/`);
}
await page.waitForSelector("text=我的工作区");
check("登录后进入首页", true);
// ② 首页与工作区页是同一套外壳:侧栏都在,宽度不变
const homeRail = await page.locator("aside").first().boundingBox();
const homeMain = await page.locator("main").first().boundingBox();
check("首页有左侧栏", Boolean(homeRail && homeRail.width > 200), JSON.stringify(homeRail));
check("首页内容区贴着侧栏", Boolean(homeMain && homeMain.x >= (homeRail?.x ?? 0)));
await page.screenshot({ path: `${OUT}/account-home.png` });
await page.locator("main button", { hasText: "我的备忘" }).first().click();
await page.waitForURL(/\/w\//);
await page.waitForSelector("aside");
const wsRail = await page.locator("aside").first().boundingBox();
check(
"进了工作区侧栏宽度不变",
Boolean(wsRail && homeRail && Math.abs(wsRail.width - homeRail.width) < 1),
`home=${homeRail?.width} ws=${wsRail?.width}`,
);
await page.screenshot({ path: `${OUT}/account-workspace.png` });
// ③ 账号设置:侧栏有入口,能改昵称、能设/改密码
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
const accountRail = await page.locator("aside").first().boundingBox();
check(
"账号页复用了同一条侧栏",
Boolean(accountRail && Math.abs(accountRail.width - (homeRail?.width ?? 0)) < 1),
);
const nickname = `用户${Math.floor(Math.random() * 9000 + 1000)}`;
// 等 /auth/me 回填完再改,否则 backfill 会把刚输入的值盖回去
await page.waitForFunction(
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
);
await page.getByLabel("昵称").fill(nickname);
await page.getByRole("button", { name: "保存昵称" }).click();
await page.waitForSelector("text=昵称已更新");
check("昵称保存成功", true);
const currentField = page.getByLabel("当前密码");
const hadPassword = (await currentField.count()) > 0;
if (hadPassword) await currentField.fill(savedPassword());
const password = randomPassword();
await page.getByLabel(hadPassword ? "新密码" : "密码", { exact: true }).fill(password);
await page.getByLabel("确认密码").fill(password);
await page.getByRole("button", { name: /设置密码|修改密码/ }).click();
await page.waitForSelector("text=/密码已(设置|修改)/");
writeFileSync(PWD_FILE, password, "utf8");
await page.screenshot({ path: `${OUT}/account-settings.png` });
check(hadPassword ? "密码修改成功" : "密码设置成功", true);
// 刷新后仍是"已设置密码",说明后端真的存住了
await page.reload();
await page.waitForSelector("text=已设置密码", { timeout: 15_000 });
check("刷新后仍显示已设置密码", true);
// ④ 退出,改用密码登录
await page.goto(`${WEB}/account`);
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL(/\/login/);
await page.getByRole("button", { name: "密码", exact: true }).click();
await page.getByLabel("手机号").fill(PHONE);
await page.getByLabel("密码").fill("wrong-pass1");
await page.getByRole("button", { name: "登录" }).click();
await page.waitForSelector("text=手机号或密码不对");
check("错误密码被拒绝", true);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
check("密码登录成功", true);
const me = await page.evaluate(async (api) => {
const token = localStorage.getItem("dadao.access");
const r = await fetch(`${api}/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
return r.json();
}, API);
check(
"接口里 has_password=true、昵称是刚改的",
me?.data?.user?.has_password === true && me?.data?.user?.nickname === nickname,
JSON.stringify(me?.data?.user ?? null),
);
// ⑤ 退出后用验证码也能进来(登录页两种模式都得通)
await page.goto(`${WEB}/account`);
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL(/\/login/);
await page.getByRole("button", { name: "验证码", exact: true }).click();
await page.getByLabel("手机号").fill(PHONE);
await page.getByRole("button", { name: /获取验证码|重新获取/ }).click();
await page.waitForTimeout(1200);
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
const codeLoginOk = await page
.waitForURL(`${WEB}/`, { timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (codeLoginOk) check("验证码登录也照旧能用", true);
else console.log("SKIP 验证码登录(本次发码被 60 秒节流挡住)");
const realErrors = errors.filter((e) => !e.includes("429") && !e.includes("400"));
check("控制台零报错", realErrors.length === 0, realErrors.slice(0, 3).join(" | "));
// ⑥ 窄屏:侧栏收成顶条,别把整页挤成一列
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(WEB);
await page.waitForSelector("main h1");
check("窄屏首页是顶条而不是侧栏", (await page.locator("header").count()) > 0);
await page.screenshot({ path: `${OUT}/account-home-mobile.png`, fullPage: false });
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
await page.screenshot({ path: `${OUT}/account-settings-mobile.png`, fullPage: false });
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
}
main().catch((err) => {
console.log(`FAIL 脚本异常 — ${err?.message ?? err}`);
process.exit(1);
});
+2
View File
@@ -12,6 +12,7 @@ import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge"; import { KnowledgePage } from "./pages/Knowledge";
import { NotificationsPage } from "./pages/Notifications"; import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join"; import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
interface AuthState { interface AuthState {
user: User | null; user: User | null;
@@ -87,6 +88,7 @@ export function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/w/:wsId" element={<TasksPage />} /> <Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} /> <Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} /> <Route path="/w/:wsId/tickets" element={<TicketsPage />} />
+19 -5
View File
@@ -11,6 +11,7 @@ import {
Inbox, Inbox,
Logo, Logo,
Logout, Logout,
Settings,
TicketIcon, TicketIcon,
Users, Users,
X, X,
@@ -209,13 +210,26 @@ export function AppShell({
<div className="border-t border-line-200 px-3 py-3"> <div className="border-t border-line-200 px-3 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700"> <Link
to="/account"
title="账号设置"
className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700 transition hover:bg-ink-300"
>
{initial(user?.nickname ?? "我")} {initial(user?.nickname ?? "我")}
</span> </Link>
<div className="min-w-0 flex-1"> <Link to="/account" className="group min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div> <div className="truncate text-xs font-medium text-ink-800 group-hover:text-primary-700">
{user?.nickname}
</div>
<div className="truncate text-2xs text-ink-400">{phoneMasked}</div> <div className="truncate text-2xs text-ink-400">{phoneMasked}</div>
</div> </Link>
<Link
to="/account"
title="账号设置"
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
<Settings size={15} />
</Link>
<button <button
onClick={signOut} onClick={signOut}
title="退出登录" title="退出登录"
+116
View File
@@ -0,0 +1,116 @@
import { Link } from "react-router-dom";
import { useAuth } from "../App";
import { Logout, Logo, Settings } from "./icons";
/**
* 工作区里侧栏长什么样,工作区外面(工作区列表、账号设置)就长什么样。
*
* 之前首页是居中窄栏、进了工作区又是全宽,宽度跳变一次、导航位置换一次,
* 像是两个产品。这里把外壳收成一处,首页与账号页复用它。
*/
export function SideRail({ activeId }: { activeId?: string | null }) {
const { user, workspaces, signOut } = useAuth();
const masked = user?.phone ? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2") : "";
return (
<>
{/* 桌面:左侧栏 */}
<aside className="sticky top-0 z-20 hidden h-screen w-60 shrink-0 flex-col border-r border-line-200 bg-paper-50 lg:flex xl:w-[264px]">
<div className="px-5 pb-4 pt-5">
<Link to="/" className="flex items-center gap-2.5">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</Link>
</div>
<div className="mt-1 px-3">
<div className="px-2 pb-1 text-2xs font-medium text-ink-400">我的工作区</div>
</div>
<nav className="mt-1 flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
{workspaces.map((w) => {
const isActive = w.id === activeId;
return (
<Link
key={w.id}
to={`/w/${w.id}`}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs transition ${
isActive
? "bg-paper-200/70 font-medium text-ink-900"
: "text-ink-600 hover:bg-paper-100"
}`}
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-line-100 text-2xs font-semibold text-ink-600">
{w.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="truncate">{w.name}</span>
</Link>
);
})}
{workspaces.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-relaxed text-ink-400">
还没有工作区,右边建一个就能开始。
</p>
) : null}
<div className="my-1 h-px bg-line-100" />
<Link
to="/account"
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"
>
<Settings size={14} className="text-ink-400" />
账号设置
</Link>
</nav>
<div className="border-t border-line-200 px-3 py-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
<div className="truncate text-2xs text-ink-400">{masked}</div>
</div>
<button
onClick={signOut}
title="退出登录"
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
<Logout size={15} />
</button>
</div>
</div>
</aside>
{/* 移动端:顶条 */}
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/95 backdrop-blur lg:hidden">
<div className="flex items-center justify-between px-4 py-2.5">
<Link to="/" className="flex items-center gap-2">
<Logo size={24} />
<span className="text-sm font-semibold text-ink-900">达道Task</span>
</Link>
<div className="flex items-center gap-1">
<Link
to="/account"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="账号设置"
>
<Settings size={16} />
</Link>
<button
onClick={signOut}
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="退出登录"
>
<Logout size={16} />
</button>
</div>
</div>
</header>
</>
);
}
+14
View File
@@ -220,6 +220,20 @@ export const Logout = (p: IconProps) => (
</svg> </svg>
); );
export const Settings = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="3" />
<path d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M17.9 6.1l-1.4 1.4M7.5 16.5l-1.4 1.4M17.9 17.9l-1.4-1.4M7.5 7.5 6.1 6.1" />
</svg>
);
export const User = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="8.5" r="3.5" />
<path d="M5 20c0-3.3 3.1-5.5 7-5.5s7 2.2 7 5.5" />
</svg>
);
export const Flag = (p: IconProps) => ( export const Flag = (p: IconProps) => (
<svg {...base(p)}> <svg {...base(p)}>
<path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" /> <path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" />
+3
View File
@@ -2,6 +2,9 @@ export interface User {
id: string; id: string;
phone: string; phone: string;
nickname: string; nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
created_at?: string; created_at?: string;
} }
+244
View File
@@ -0,0 +1,244 @@
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 { User } from "../lib/types";
/**
* 账号设置:昵称、登录密码、退出登录。
*
* 密码是**可选**的第二条登录路径:没设过的人一直走验证码,
* 界面上的文案要能让人看懂"现在设的是第一道密码"还是"在改已有的密码"。
*/
/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */
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>
);
}
export function AccountPage() {
const { user, loading, reload, signOut } = useAuth();
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
const [profileBusy, setProfileBusy] = useState(false);
const [profileMsg, setProfileMsg] = useState<string | null>(null);
const [profileError, setProfileError] = useState<string | null>(null);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [pwdBusy, setPwdBusy] = useState(false);
const [pwdMsg, setPwdMsg] = useState<string | null>(null);
const [pwdError, setPwdError] = useState<string | null>(null);
useEffect(() => {
if (!loading && !user) navigate("/login", { replace: true });
}, [loading, user, navigate]);
useEffect(() => {
if (user?.nickname) setNickname(user.nickname);
}, [user?.nickname]);
const hasPassword = Boolean(user?.has_password);
async function saveProfile(event: React.FormEvent) {
event.preventDefault();
setProfileBusy(true);
setProfileMsg(null);
setProfileError(null);
try {
await api.patch<User>("/auth/me", { nickname: nickname.trim() });
await reload();
setProfileMsg("昵称已更新");
} catch (err) {
setProfileError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setProfileBusy(false);
}
}
async function savePassword(event: React.FormEvent) {
event.preventDefault();
setPwdMsg(null);
setPwdError(null);
if (newPassword !== confirm) {
setPwdError("两次输入的新密码不一样");
return;
}
setPwdBusy(true);
try {
await api.post("/auth/password", {
password: newPassword,
...(hasPassword ? { old_password: oldPassword } : {}),
});
await reload();
setOldPassword("");
setNewPassword("");
setConfirm("");
setPwdMsg(hasPassword ? "密码已修改,下次可用新密码登录" : "密码已设置,之后可以用密码登录");
} catch (err) {
setPwdError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setPwdBusy(false);
}
}
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-7">
<h1 className="font-display text-2xl font-semibold">账号设置</h1>
<p className="mt-2 text-sm text-ink-500">
这里改的是你自己:昵称、登录密码、退出登录。工作区里的成员与权限在各自的工作区里管。
</p>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<section className="card p-5">
<SectionLabel hint="别人在成员列表里看到的名字">基本信息</SectionLabel>
<form className="mt-4 space-y-4" onSubmit={saveProfile}>
<div className="flex items-center gap-3">
<span className="grid h-12 w-12 place-items-center rounded-full bg-primary-900 text-base font-semibold text-paper-50">
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-ink-800">
{user?.nickname ?? "—"}
</div>
<div className="text-2xs text-ink-400">
{user?.created_at
? `加入于 ${new Date(user.created_at).toLocaleDateString("zh-CN")}`
: "—"}
</div>
</div>
</div>
<Field label="昵称">
<input
className="input"
value={nickname}
maxLength={32}
aria-label="昵称"
onChange={(e) => setNickname(e.target.value)}
/>
</Field>
<Field label="手机号">
<input
className="input bg-paper-100 text-ink-500"
value={user?.phone ?? ""}
readOnly
aria-label="手机号"
/>
</Field>
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
</p>
{profileMsg && <InlineAlert tone="success">{profileMsg}</InlineAlert>}
{profileError && <InlineAlert>{profileError}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="sm"
disabled={profileBusy || !nickname.trim() || nickname.trim() === user?.nickname}
>
{profileBusy ? "保存中…" : "保存昵称"}
</Button>
</form>
</section>
<div className="space-y-4">
<section className="card p-5">
<SectionLabel hint="验证码始终可用,密码是可选的第二条路">登录密码</SectionLabel>
<p className="mt-3 text-xs leading-relaxed text-ink-500">
{hasPassword
? "已设置密码。登录页可以选「密码」进入;改密码要先输当前密码。"
: "还没有设置密码。现在只能用手机号 + 验证码登录;设一个密码,换手机或短信不通时也能进。"}
</p>
<form className="mt-4 space-y-3" onSubmit={savePassword}>
{hasPassword ? (
<Field label="当前密码">
<input
type="password"
className="input"
autoComplete="current-password"
value={oldPassword}
aria-label="当前密码"
onChange={(e) => setOldPassword(e.target.value)}
/>
</Field>
) : null}
<Field label={hasPassword ? "新密码" : "密码"}>
<input
type="password"
className="input"
autoComplete="new-password"
placeholder="至少 8 位,字母和数字都要有"
value={newPassword}
aria-label={hasPassword ? "新密码" : "密码"}
onChange={(e) => setNewPassword(e.target.value)}
/>
</Field>
<Field label="再输一次">
<input
type="password"
className="input"
autoComplete="new-password"
value={confirm}
aria-label="确认密码"
onChange={(e) => setConfirm(e.target.value)}
/>
</Field>
{pwdMsg && <InlineAlert tone="success">{pwdMsg}</InlineAlert>}
{pwdError && <InlineAlert>{pwdError}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="sm"
disabled={
pwdBusy ||
newPassword.length < 8 ||
!confirm ||
(hasPassword && !oldPassword)
}
>
{pwdBusy ? "保存中…" : hasPassword ? "修改密码" : "设置密码"}
</Button>
</form>
</section>
<section className="card p-5">
<SectionLabel hint="在这台设备上清掉登录状态">退出</SectionLabel>
<p className="mt-3 text-xs leading-relaxed text-ink-500">
退出只会清掉这台浏览器里的令牌;工作区里的数据不受影响。
</p>
<Button variant="ghost" size="sm" className="mt-3" onClick={signOut}>
退出登录
</Button>
</section>
</div>
</div>
</div>
</main>
</div>
);
}
+137 -116
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../App"; import { useAuth } from "../App";
import { ArrowRight, ChevronRight, Logo, Logout, Plus } from "../components/icons"; import { ArrowRight, ChevronRight, Plus } from "../components/icons";
import { SideRail } from "../components/SideRail";
import { Button, EmptyState, Field, InlineAlert } from "../components/ui"; import { Button, EmptyState, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client"; import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types"; import type { Workspace } from "../lib/types";
@@ -19,10 +20,17 @@ function greeting() {
return "晚上好"; return "晚上好";
} }
/**
* 首页 = 工作区总览。
*
* 壳和工作区里完全一样(同样的侧栏、同样的通栏内容区),
* 这样"点进工作区"只是换内容,宽度和导航位置都不跳。
*/
export function HomePage() { export function HomePage() {
const { user, workspaces, loading, reload, signOut } = useAuth(); const { user, workspaces, loading, reload } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -38,6 +46,7 @@ export function HomePage() {
try { try {
const ws = await api.post<Workspace>("/workspaces", { name: name.trim() }); const ws = await api.post<Workspace>("/workspaces", { name: name.trim() });
setName(""); setName("");
setCreating(false);
await reload(); await reload();
navigate(`/w/${ws.id}`); navigate(`/w/${ws.id}`);
} catch (err) { } catch (err) {
@@ -48,123 +57,135 @@ export function HomePage() {
} }
return ( return (
<div className="min-h-full bg-paper-100"> <div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/90 backdrop-blur"> <SideRail />
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-3 lg:px-10">
<div className="flex items-center gap-2.5">
<Logo size={26} />
<span className="text-sm font-semibold tracking-tight text-ink-900">达道Task</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-ink-500 sm:inline">
{user?.nickname}
{user?.phone ? ` · ${user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")}` : ""}
</span>
<Button variant="subtle" size="sm" onClick={signOut} title="退出登录">
<Logout size={14} />
退出
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl px-5 py-8 lg:px-10 lg:py-12"> <main className="min-w-0 flex-1">
<div className="mb-9"> <div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
<p className="text-xs font-medium tracking-wide text-clay-600"> <div className="mb-7">
{new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })} <p className="text-xs font-medium tracking-wide text-clay-600">
</p> {new Date().toLocaleDateString("zh-CN", {
<h1 className="mt-1.5 font-display text-3xl font-semibold"> month: "long",
{greeting()},{user?.nickname ?? ""} day: "numeric",
</h1> weekday: "long",
<p className="mt-2 max-w-xl text-sm leading-relaxed text-ink-500"> })}
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[1.55fr_1fr]">
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
</div>
{loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
))}
</div>
) : workspaces.length === 0 ? (
<div className="card">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="group card flex items-center gap-3 p-4 transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
{ws.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{ws.name}
</span>
<span className="mt-0.5 block text-2xs text-ink-400">
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
</span>
</span>
<ChevronRight
size={16}
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
/>
</Link>
</li>
))}
</ul>
)}
</section>
<aside className="card h-fit p-5">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-clay-50 text-clay-600">
<Plus size={15} />
</span>
<h2 className="text-sm font-medium text-ink-800">新建工作区</h2>
</div>
<p className="mt-2 text-xs leading-relaxed text-ink-400">
一个工作区对应一支团队或一个生活领域。成员之间共享任务、工单与知识。
</p> </p>
<h1 className="mt-1.5 font-display text-3xl font-semibold">
{greeting()},{user?.nickname ?? ""}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-500">
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
</p>
</div>
<form className="mt-4 space-y-3" onSubmit={createWorkspace}> <div className="mb-3 flex items-center justify-between">
<Field label="名称"> <h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
<input <div className="flex items-center gap-3">
className="input" <span className="text-2xs text-ink-400">{workspaces.length} 个</span>
placeholder="例如:达道科技 / 我的事项" {!creating ? (
value={name} <Button variant="ghost" size="sm" onClick={() => setCreating(true)}>
maxLength={64} <Plus size={14} />
onChange={(e) => setName(e.target.value)} 新建
/> </Button>
</Field> ) : null}
{error && <InlineAlert>{error}</InlineAlert>}
<Button type="submit" variant="primary" className="w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
{busy ? null : <ArrowRight size={14} />}
</Button>
</form>
<div className="mt-4 border-t border-line-100 pt-3 text-2xs leading-relaxed text-ink-400">
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,
不需要别人手动拉你。
</div> </div>
</aside> </div>
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{[0, 1, 2].map((i) => (
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
))}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => navigate(`/w/${ws.id}`)}
className="group card flex items-center gap-3 p-4 text-left transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
{ws.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">{ws.name}</span>
<span className="mt-0.5 block text-2xs text-ink-400">
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
</span>
</span>
<ChevronRight
size={16}
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
/>
</button>
))}
{creating ? (
<form
onSubmit={createWorkspace}
className="card space-y-3 border-clay-500/30 p-4 sm:col-span-2 xl:col-span-1"
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-md bg-clay-50 text-clay-600">
<Plus size={14} />
</span>
<span className="text-sm font-medium text-ink-800">新建工作区</span>
</div>
<Field label="名称">
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
autoFocus
onChange={(e) => setName(e.target.value)}
/>
</Field>
{error && <InlineAlert>{error}</InlineAlert>}
<div className="flex items-center gap-2">
<Button type="submit" variant="primary" size="sm" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
{busy ? null : <ArrowRight size={14} />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreating(false);
setError(null);
}}
>
取消
</Button>
</div>
</form>
) : (
<button
onClick={() => setCreating(true)}
className="flex min-h-[92px] items-center justify-center gap-2 rounded-xl border border-dashed border-line-300 text-sm text-ink-400 transition hover:border-primary-300 hover:text-primary-600"
>
<Plus size={16} />
新建工作区
</button>
)}
{workspaces.length === 0 && !creating ? (
<div className="card sm:col-span-2 xl:col-span-3">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : null}
</div>
)}
<p className="mt-6 text-2xs leading-relaxed text-ink-400">
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,不需要别人手动拉你。
</p>
</div> </div>
</main> </main>
</div> </div>
+75 -25
View File
@@ -37,6 +37,8 @@ export function LoginPage() {
const next = safeNext(params.get("next")); const next = safeNext(params.get("next"));
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("code");
const [devCode, setDevCode] = useState<string | null>(null); const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -69,7 +71,13 @@ export function LoginPage() {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code }); const data =
mode === "code"
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
phone,
password,
});
session.save(data.tokens); session.save(data.tokens);
await reload(); await reload();
navigate(next ?? "/", { replace: true }); navigate(next ?? "/", { replace: true });
@@ -135,9 +143,36 @@ export function LoginPage() {
</div> </div>
<h2 className="text-2xl font-semibold">登录</h2> <h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p> <p className="mt-1.5 text-sm text-ink-500">
{mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
</p>
<form className="mt-7 space-y-4" onSubmit={submit}> <div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
{(
[
["code", "验证码"],
["password", "密码"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => {
setMode(key);
setError(null);
}}
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
}`}
>
{label}
</button>
))}
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="手机号"> <Field label="手机号">
<input <input
id="phone" id="phone"
@@ -151,29 +186,44 @@ export function LoginPage() {
/> />
</Field> </Field>
<Field label="验证码"> {mode === "code" ? (
<div className="flex gap-2"> <Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
) : (
<Field label="密码">
<input <input
id="code" id="password"
className="input h-11 tracking-[0.3em]" type="password"
inputMode="numeric" className="input h-11"
autoComplete="one-time-code" autoComplete="current-password"
placeholder="6 位数字" placeholder="你的登录密码"
value={code} value={password}
onChange={(e) => setCode(e.target.value.trim())} onChange={(e) => setPassword(e.target.value)}
maxLength={6} maxLength={64}
/> />
<Button </Field>
type="button" )}
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
{devCode && ( {devCode && (
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700"> <div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
@@ -188,7 +238,7 @@ export function LoginPage() {
variant="primary" variant="primary"
size="lg" size="lg"
className="mt-2 w-full" className="mt-2 w-full"
disabled={busy || !phoneValid || code.length < 4} disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
> >
{busy ? "处理中…" : "登录"} {busy ? "处理中…" : "登录"}
</Button> </Button>