feat(auth): 接入邮箱验证与密码找回流程
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
### 1.2 命名与格式
|
||||
|
||||
- 请求与响应字段一律 **snake_case**(如 `cloud_id`、`page_size`、`received_like_count`)
|
||||
- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 6 条
|
||||
- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 5 条
|
||||
- UUID 字段为标准 UUID 字符串
|
||||
|
||||
### 1.3 响应形态
|
||||
@@ -91,7 +91,7 @@
|
||||
|---|---|---|
|
||||
| `id` | uuid | 用户 ID |
|
||||
| `name` | string | 用户名 |
|
||||
| `email` | string | 邮箱(注意 2.3 第 8 条的暴露范围问题) |
|
||||
| `email` | string | 邮箱(注意 2.3 第 7 条的暴露范围问题) |
|
||||
| `avatar_id` | uuid\|null | 头像 ID(当前恒为 `null`,头像功能未上线) |
|
||||
| `cloud_count` | int | 云图数(该用户上传的云图总数) |
|
||||
| `received_like_count` | int | 收到点赞数(名下云图获赞总和) |
|
||||
@@ -110,7 +110,7 @@
|
||||
| `icon_id` | uuid\|null | 图标 ID |
|
||||
| `rarity` | int | 稀有度 |
|
||||
| `description` | string | 描述 |
|
||||
| `created_at` | string | 创建时间。⚠️ 列表与详情端点的格式不一致,见 2.3 第 6 条 |
|
||||
| `created_at` | string | 创建时间。⚠️ 列表与详情端点的格式不一致,见 2.3 第 5 条 |
|
||||
|
||||
### 1.6 上传约束(`POST /cloud`)
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
| 304 | 图片 ETag 未变化,无响应 body | 继续使用本地缓存 |
|
||||
| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①,如「新密码不能与当前密码相同」) | 检查参数;形状②可解析 `error.message` 定位字段 |
|
||||
| 401 | 未认证:未携带 token,或 token 无效/过期/已失效 | 清除本地登录态,跳转登录页 |
|
||||
| 403 | 已认证但无权限:角色不足、帐号被禁用、越权访问他人资源 | 按消息场景提示 |
|
||||
| 403 | 已认证但无权限:角色不足、邮箱未确认、帐号被禁用、越权访问他人资源 | 按消息场景提示 |
|
||||
| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 |
|
||||
| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 |
|
||||
| 409 | 唯一性冲突:邮箱/用户名已被占用 | 提示用户更换 |
|
||||
@@ -176,15 +176,13 @@
|
||||
以下是文档写作时核实的现状描述,**未来可能修复**。前端如需为之写防御代码,请做好移除准备。
|
||||
|
||||
1. **两种错误形状并存**:400 校验错误不是业务错误信封(见 2.1)。
|
||||
2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) 以下端点的 `catch` 为空,数据库异常时返回纯文本 404 而非 500——`POST /auth/register`、`GET /profile/me`、`GET /admin/users`;(c) 未匹配任何路由的路径。
|
||||
3. **注册即禁用**:新注册用户的 `is_disabled` 默认为 `true`,而确认邮箱的端点(`/auth/confirm-email`)尚未实现。即**当前注册成功后无法登录**(登录返回 403「帐号已被禁用」),需后端在数据库手动启用。这是流程断点,前端联调注册流程时务必知悉。
|
||||
4. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。
|
||||
5. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。
|
||||
6. **`GET /info/cloudtype`(列表)的 `created_at` 格式与其他端点不同**:是 PostgreSQL 原生字符串(如 `2026-08-15 08:30:00.123456+00`),而非 ISO 8601(`2026-08-15T08:30:00.000Z`)。详情端点 `/info/cloudtype/:id` 及其他所有端点均为 ISO 8601。前端解析该字段时需兼容两种格式。
|
||||
7. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册会因 2.3.2(b) 返回**纯文本 404**;超长改名(`PATCH /profile/me`)返回 500。前端应自行限制输入长度。
|
||||
8. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。
|
||||
9. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。
|
||||
10. **README 与代码的 CORS 环境变量不一致**:README 写的是 `CORS_ORIGINS`(复数、逗号分隔),代码实际读取 `CORS_ORIGIN`(单数、单个 origin)。本地联调配置后端环境变量时用单数。
|
||||
2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。注册与邮箱确认失败始终返回 JSON 信封。
|
||||
3. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。
|
||||
4. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。
|
||||
5. **`GET /info/cloudtype`(列表)的 `created_at` 格式与其他端点不同**:是 PostgreSQL 原生字符串(如 `2026-08-15 08:30:00.123456+00`),而非 ISO 8601(`2026-08-15T08:30:00.000Z`)。详情端点 `/info/cloudtype/:id` 及其他所有端点均为 ISO 8601。前端解析该字段时需兼容两种格式。
|
||||
6. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册与超长改名(`PATCH /profile/me`)会返回 JSON 500。前端应自行限制输入长度。
|
||||
7. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。
|
||||
8. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。
|
||||
|
||||
---
|
||||
|
||||
@@ -203,7 +201,7 @@
|
||||
| 模式 | 适用 | 注意事项 |
|
||||
|---|---|---|
|
||||
| Cookie | 浏览器前端(推荐) | 登录后浏览器自动携带;`fetch` 需设 `credentials: "include"`;HttpOnly 使 JS 无法读取 token,天然防 XSS 窃取;可享受静默续签 |
|
||||
| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 4 条),30 分钟后需重新登录 |
|
||||
| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 3 条),30 分钟后需重新登录 |
|
||||
|
||||
### 3.3 CORS
|
||||
|
||||
@@ -218,14 +216,15 @@
|
||||
- **静默续签**:携带剩余有效期 ≤ 300 秒的 token 访问受保护端点时,服务端会在响应中**追加 `Set-Cookie` 写入新 token**。Cookie 模式下浏览器自动替换,前端无感知;Bearer 模式收不到新 token
|
||||
- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password`
|
||||
- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie
|
||||
- **立即失效**的三种情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、帐号被禁用。前两者会使该用户**所有已签发的 token 全部作废**(包括其他设备上的),并写入过期 Cookie
|
||||
- 认证中间件每次请求都会重新检查用户仍已确认邮箱且未被管理员禁用;任一条件不满足时,已有 JWT 也会被拒绝
|
||||
- **立即失效**的四种情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用。前三者会使该用户**所有已签发的 token 全部作废**(包括其他设备上的);密码修改与重置成功时还会写入过期 Cookie
|
||||
|
||||
### 3.5 认证相关错误消息对照
|
||||
|
||||
| 状态码 | 消息原文 | 场景 |
|
||||
|---|---|---|
|
||||
| 401 | `未登录` | 受保护端点未携带任何 token |
|
||||
| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出/改密后)、或帐号已被禁用 |
|
||||
| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出、改密或密码重置后),或用户不再同时满足「邮箱已确认且未禁用」 |
|
||||
| 403 | `权限不足` | 已登录但角色不满足(如非 admin 访问 `/admin`) |
|
||||
|
||||
注意「帐号被禁用」在**登录时**报 403「帐号已被禁用」,但已登录后被禁用,后续请求报的是 401「Token 无效或已过期」。
|
||||
@@ -242,8 +241,8 @@
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 7 条) | 用户名,大小写不敏感查重 |
|
||||
| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 邮箱,大小写不敏感查重 |
|
||||
| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 6 条) | 用户名,大小写不敏感查重 |
|
||||
| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 服务端去除首尾空白并转为小写后存储 |
|
||||
| `password` | string | 是 | 无强度校验 | 密码 |
|
||||
|
||||
```json
|
||||
@@ -259,31 +258,89 @@
|
||||
```json
|
||||
{
|
||||
"status_code": 201,
|
||||
"message": "注册成功"
|
||||
"message": "注册成功,请查收邮件并确认邮箱",
|
||||
"email_sent": true
|
||||
}
|
||||
```
|
||||
|
||||
数据库提交成功但邮件暂时未发送时仍返回 `201`,`email_sent` 为 `false`,消息会提示稍后使用重发验证流程。用户、密码凭据和 24 小时有效的确认记录已经保留,不应重试注册。
|
||||
|
||||
**错误**:
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
|
||||
| 409 | 邮箱已注册(大小写不敏感) | `该邮箱已被注册` | 提示换邮箱或直接登录 |
|
||||
| 409 | 已确认邮箱重复注册(大小写不敏感) | `该邮箱已被注册` | 提示直接登录 |
|
||||
| 409 | 未确认邮箱重复注册 | `该邮箱已被注册,请使用重发验证流程` | 引导至重发验证流程,不会覆盖原用户名或密码 |
|
||||
| 409 | 用户名已使用(大小写不敏感) | `该用户名已被使用` | 提示换用户名 |
|
||||
| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 |
|
||||
| 500 | 数据库、密码散列或服务端配置异常 | `注册失败` | 稍后重试;若此前已收到 201,不要重复注册 |
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- ⚠️ **注册成功后无法直接登录**:新用户默认 `is_disabled: true`,登录将返回 403(2.3 第 3 条)。联调时需后端手动启用帐号
|
||||
- 注册响应**不返回 token、不写 Cookie**,登录需另行调用 `/auth/login`
|
||||
- 新用户的 `is_disabled` 为 `false`,`email_verified_at` 为空;两个状态相互独立。注册后必须先确认邮箱,再调用 `/auth/login`
|
||||
- 注册响应**不返回 token、不写 Cookie**
|
||||
- 确认邮件由 `Opencloud <opencloud@catpl.top>` 发出,同时包含 `https://cloud.catpl.top/verify-email?token=...` 链接和原始 token 备用文本。数据库仅保存使用 `JWT_SECRET` 生成的 HMAC 摘要
|
||||
|
||||
### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件
|
||||
|
||||
> ⚠️ **未实现**(占位端点)。请求体合法时当前返回纯文本 `404 Not Found`;请求体非法时仍会被 zod 校验拦截返回 400。请勿接入。
|
||||
- **认证**:无(公开)
|
||||
|
||||
**请求体**(JSON):
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `email` | string | 是 | 与注册、登录相同:服务端去除首尾空白并转为小写;不接受旧字段 `user_email` |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "watcher@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "如果该邮箱符合条件,我们将发送验证邮件"
|
||||
}
|
||||
```
|
||||
|
||||
未知邮箱、已确认邮箱、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。前端不能根据该响应判断邮件是否实际发送,也不应显示「邮箱存在」之类的提示。
|
||||
|
||||
只有尚未确认、未被管理员禁用且不在冷却期内的用户会触发邮件发送。成功签发新 token 后,同一用户此前未消费的邮箱确认 token 全部失效;新 token 仍有 24 小时有效期,邮件内容与注册邮件相同。已消费记录会保留作为审计信息。
|
||||
|
||||
重叠请求会按用户串行处理,至多签发一个可用 token。数据库签发或 Resend 失败不会改变上述公共响应。
|
||||
|
||||
**错误**:请求体校验失败返回 `400` zod 形状(2.1)。
|
||||
|
||||
### 4.3 `POST /auth/confirm-email` — 确认邮箱
|
||||
|
||||
> ⚠️ **未实现**(占位端点),行为同 4.2。该端点是解除「注册即禁用」(2.3 第 3 条)的设计入口,上线前注册流程是断的。
|
||||
- **认证**:无(公开)
|
||||
|
||||
前端从确认链接读取 `token`,再提交:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "邮箱确认成功,请重新登录"
|
||||
}
|
||||
```
|
||||
|
||||
确认会在一个数据库事务中消费未过期、未消费且用途为 `email_confirmation` 的记录,并写入用户的邮箱确认时间。它不会签发 JWT、不会写认证 Cookie,也绝不修改管理员禁用状态。
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | token 未知、过期、已消费,或来自其他用途 | `确认链接无效或已过期` | 统一提示链接无效,并引导重发 |
|
||||
| 500 | 数据库或服务端配置异常 | `确认邮箱失败` | 稍后重试 |
|
||||
|
||||
### 4.4 `POST /auth/login` — 登录
|
||||
|
||||
@@ -293,7 +350,7 @@
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `email` | string | 是 | 邮箱,服务端按大小写不敏感匹配 |
|
||||
| `email` | string | 是 | 服务端去除首尾空白、转为小写后匹配 |
|
||||
| `password` | string | 是 | 密码 |
|
||||
|
||||
```json
|
||||
@@ -317,7 +374,8 @@
|
||||
|---|---|---|---|
|
||||
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
|
||||
| 401 | 邮箱不存在或密码错误(两者共用,防枚举) | `邮箱或密码错误` | 统一提示「邮箱或密码错误」 |
|
||||
| 403 | 帐号被禁用(含新注册未启用的帐号) | `帐号已被禁用` | 提示联系管理员 |
|
||||
| 403 | 帐号被管理员禁用 | `帐号已被禁用` | 提示联系管理员 |
|
||||
| 403 | 密码正确但邮箱尚未确认 | `邮箱尚未验证` | 引导至重发验证流程 |
|
||||
| 500 | 服务端异常 | `登录失败` | 稍后重试 |
|
||||
|
||||
### 4.5 `POST /auth/logout` — 登出
|
||||
@@ -343,13 +401,75 @@
|
||||
|
||||
**注意事项**:登出会使该用户**所有设备**的 token 立即失效(`tokenVersion` 递增),不只是当前会话。
|
||||
|
||||
### 4.6 `POST /auth/forget-password` — 申请重置密码
|
||||
### 4.6 `POST /auth/forgot-password` — 申请重置密码
|
||||
|
||||
> ⚠️ **未实现**(占位端点),行为同 4.2。请勿接入。
|
||||
- **认证**:无(公开)
|
||||
|
||||
**请求体**(JSON):
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `email` | string | 是 | 服务端去除首尾空白并转为小写;不接受旧字段 `user_email` |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "watcher@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "如果该邮箱符合条件,我们将发送密码重置邮件"
|
||||
}
|
||||
```
|
||||
|
||||
未知邮箱、邮箱未确认、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。只有邮箱已确认、未被禁用且不在冷却期内的用户会触发邮件发送。
|
||||
|
||||
新签发的 `password_reset` token 有效期为 30 分钟,并使此前未消费的密码重置 token 失效;它与邮箱确认 token 用 purpose 隔离。邮件由 `Opencloud <opencloud@catpl.top>` 发出,链接到 `https://cloud.catpl.top/reset-password?email=...&token=...`。数据库和 Resend 失败不改变公共响应。
|
||||
|
||||
**错误**:请求体校验失败返回 `400` zod 形状(2.1)。没有 `/auth/forget-password` 兼容路径。
|
||||
|
||||
### 4.7 `POST /auth/reset-password` — 重置密码
|
||||
|
||||
> ⚠️ **未实现**(占位端点),行为同 4.2。请勿接入。
|
||||
- **认证**:无(公开)
|
||||
|
||||
**请求体**(JSON):
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `email` | string | 是 | 邮件链接中的邮箱;服务端再次规范化 |
|
||||
| `token` | string | 是 | 邮件链接中的原始 token |
|
||||
| `new_password` | string | 是 | 新密码;不能与当前密码相同,不额外扩大密码策略 |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "watcher@example.com",
|
||||
"token": "0123456789abcdef0123456789abcdef",
|
||||
"new_password": "new-s3cret-password"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`,同时写入过期认证 Cookie
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "密码重置成功,请使用新密码重新登录"
|
||||
}
|
||||
```
|
||||
|
||||
成功时,服务端在同一数据库事务中消费 token、更新密码哈希和凭据更新时间,并递增 `tokenVersion`。此前在所有设备签发的 JWT 随即失效;响应不会签发新 JWT,用户必须重新登录。
|
||||
|
||||
token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose、有效期、未消费状态和目标用户;用户在消费时仍须邮箱已确认且未被管理员禁用。重置不会修改邮箱确认时间或管理员禁用状态。
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | token 未知、过期、已消费、已被替换、用途错误、邮箱不匹配,或用户不再符合状态要求 | `重置链接无效或已过期` | 统一提示链接无效,重新申请 |
|
||||
| 400 | 新密码与当前密码相同 | `新密码不能与当前密码相同` | 要求输入不同密码 |
|
||||
| 500 | 数据库或服务端配置异常 | `重置密码失败` | 稍后重试 |
|
||||
|
||||
### 4.8 `PATCH /auth/password` — 修改密码
|
||||
|
||||
@@ -847,7 +967,7 @@
|
||||
| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 |
|
||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||
|
||||
**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 8 条)。展示他人资料页时不应展示邮箱字段。
|
||||
**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。
|
||||
|
||||
### 6.6 `GET /profile/:user_id/likes` — 查看用户点赞记录
|
||||
|
||||
@@ -905,7 +1025,7 @@
|
||||
]
|
||||
```
|
||||
|
||||
**注意事项**:⚠️ 本端点的 `created_at` 是 PostgreSQL 原生格式(上例),**不是** ISO 8601(2.3 第 6 条),解析时自行兼容。
|
||||
**注意事项**:⚠️ 本端点的 `created_at` 是 PostgreSQL 原生格式(上例),**不是** ISO 8601(2.3 第 5 条),解析时自行兼容。
|
||||
|
||||
**错误**:500 `服务器发生内部错误`。
|
||||
|
||||
@@ -955,7 +1075,7 @@
|
||||
|
||||
### 8.4 `POST /admin/user` — 创建用户
|
||||
|
||||
> ⚠️ **假端点**(2.3 第 5 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
|
||||
> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
|
||||
|
||||
### 8.5 `GET /admin/clouds` — 全状态云图列表
|
||||
|
||||
|
||||
@@ -318,6 +318,17 @@ async function handleLogout() {
|
||||
登录
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="no-underline"
|
||||
>
|
||||
<span
|
||||
class="oc-header-button oc-header-button--sky"
|
||||
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
||||
>
|
||||
注册
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</template>
|
||||
</NSpace>
|
||||
|
||||
+3
-1
@@ -141,7 +141,9 @@ export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
email_verified: false,
|
||||
// Protected profile routes reject unverified accounts, so a successfully
|
||||
// loaded current profile is necessarily email-verified.
|
||||
email_verified: true,
|
||||
username: profile.name,
|
||||
avatar_url: null,
|
||||
role: profile.role,
|
||||
|
||||
+14
-6
@@ -24,25 +24,33 @@ const router = createRouter({
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/UnavailableAuthView.vue'),
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { title: '注册', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/resend-confirmation',
|
||||
name: 'resend-confirmation',
|
||||
component: () => import('@/views/auth/ResendConfirmationView.vue'),
|
||||
meta: { title: '重发验证邮件', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/views/auth/UnavailableAuthView.vue'),
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: { title: '忘记密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/auth/confirm',
|
||||
path: '/verify-email',
|
||||
alias: '/auth/confirm',
|
||||
name: 'auth-confirm',
|
||||
component: () => import('@/views/auth/UnavailableAuthView.vue'),
|
||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||
meta: { title: '确认邮箱', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/auth/reset-password',
|
||||
path: '/reset-password',
|
||||
alias: '/auth/reset-password',
|
||||
name: 'auth-reset-password',
|
||||
component: () => import('@/views/auth/UnavailableAuthView.vue'),
|
||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||
meta: { title: '重置密码', noindex: true },
|
||||
},
|
||||
{
|
||||
|
||||
+48
-1
@@ -1,7 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
||||
import type { BackendUserProfile, LoginResponse } from '@/types/api'
|
||||
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -45,6 +45,48 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await fetchMe()
|
||||
}
|
||||
|
||||
async function register(name: string, email: string, password: string) {
|
||||
return await apiRequest<RegisterResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { name, email, password },
|
||||
})
|
||||
}
|
||||
|
||||
async function resendConfirmation(email: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/resend-confirmation', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmEmail(token: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/confirm-email', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token },
|
||||
})
|
||||
}
|
||||
|
||||
async function sendPasswordReset(email: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPassword(email: string, token: string, newPassword: string) {
|
||||
const response = await apiRequest<ActionResponse>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, token, new_password: newPassword },
|
||||
})
|
||||
clearAuth()
|
||||
return response
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||
@@ -94,6 +136,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
resendConfirmation,
|
||||
confirmEmail,
|
||||
sendPasswordReset,
|
||||
resetPassword,
|
||||
logout,
|
||||
updateUsername,
|
||||
updatePassword,
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface ActionResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse extends ActionResponse {
|
||||
email_sent: boolean
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
}
|
||||
|
||||
@@ -1,7 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||
const error = ref('')
|
||||
const countdown = ref(5)
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
if (route.query.token !== undefined) {
|
||||
window.history.replaceState(window.history.state, '', route.path)
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
if (timer) clearInterval(timer)
|
||||
void router.push('/login')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token) {
|
||||
error.value = '确认链接缺少验证信息'
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await authStore.confirmEmail(token)
|
||||
state.value = 'success'
|
||||
startCountdown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '确认链接无效或已过期'
|
||||
state.value = 'failed'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnavailableAuthView />
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="state === 'success'" status="success" title="邮箱确认成功" description="现在可以使用邮箱和密码登录。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
||||
<RouterLink to="/login" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--teal">立即登录</NButton></RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
<NResult v-else-if="state === 'failed'" status="error" title="邮箱确认失败" :description="error">
|
||||
<template #footer>
|
||||
<RouterLink to="/resend-confirmation" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--sky">重新发送验证邮件</NButton></RouterLink>
|
||||
</template>
|
||||
</NResult>
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<NSpin size="large" />
|
||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在确认邮箱...</h1>
|
||||
<p class="mt-2 text-slate-500">请稍候</p>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const cooldown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const buttonLabel = computed(() => loading.value ? '发送中...' : cooldown.value > 0 ? `重新发送(${cooldown.value}s)` : '重新发送')
|
||||
|
||||
function startCooldown() {
|
||||
if (timer) clearInterval(timer)
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
const value = email.value.trim()
|
||||
if (!value || value.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.sendPasswordReset(value)
|
||||
email.value = value
|
||||
submitted.value = true
|
||||
startCooldown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnavailableAuthView />
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="submitted" status="success" title="请求已受理" description="如果该邮箱符合条件,我们会发送密码重置邮件。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky" :disabled="cooldown > 0" :loading="loading" @click="submit">{{ buttonLabel }}</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton></RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</p>
|
||||
</div>
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="邮箱">
|
||||
<NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" />
|
||||
</NFormItem>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">{{ loading ? '发送中...' : '发送重置邮件' }}</NButton>
|
||||
</NForm>
|
||||
<p class="mt-6 text-sm text-slate-500"><RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">返回登录</RouterLink></p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -12,6 +12,7 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证'))
|
||||
|
||||
function getSafeRedirect(value: unknown) {
|
||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
||||
@@ -106,6 +107,14 @@ async function handleLogin() {
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
<div v-if="needsEmailConfirmation" class="mt-2">
|
||||
<RouterLink
|
||||
:to="{ path: '/resend-confirmation', query: { email } }"
|
||||
class="font-semibold text-rose-700 underline underline-offset-2"
|
||||
>
|
||||
重新发送验证邮件
|
||||
</RouterLink>
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
@@ -120,8 +129,17 @@ async function handleLogin() {
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<div class="mt-6 text-sm text-slate-500">
|
||||
注册与密码找回暂未开放;如需账号协助,请联系管理员。
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<span>
|
||||
还没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-sky-700 hover:text-sky-800">立即注册</RouterLink>
|
||||
</span>
|
||||
<RouterLink
|
||||
:to="{ path: '/forgot-password', query: { email } }"
|
||||
class="font-semibold text-teal-700 hover:text-teal-800"
|
||||
>
|
||||
忘记密码
|
||||
</RouterLink>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const name = ref('')
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const registered = ref(false)
|
||||
const emailDelivered = ref(true)
|
||||
|
||||
const canResend = computed(() =>
|
||||
error.value.includes('重发验证') || error.value.includes('已被注册,请使用重发'),
|
||||
)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
const trimmedName = name.value.trim()
|
||||
const trimmedEmail = email.value.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
error.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (trimmedName.length > 16) {
|
||||
error.value = '用户名不能超过 16 个字符'
|
||||
return
|
||||
}
|
||||
if (!trimmedEmail || trimmedEmail.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
error.value = '密码至少需要 8 位'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
|
||||
email.value = trimmedEmail
|
||||
emailDelivered.value = response.email_sent
|
||||
registered.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnavailableAuthView />
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||
加入天空探索者
|
||||
<span class="block text-sky-700">建立你的云图档案</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Step 1</div>
|
||||
<div class="oc-stat-value">Join</div>
|
||||
<div class="oc-field-help">填写账号资料</div>
|
||||
</div>
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Step 2</div>
|
||||
<div class="oc-stat-value">Verify</div>
|
||||
<div class="oc-field-help">通过邮件确认邮箱</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||
<NResult
|
||||
v-if="registered"
|
||||
:status="emailDelivered ? 'success' : 'warning'"
|
||||
title="确认你的邮箱"
|
||||
:description="emailDelivered
|
||||
? '确认邮件已经发送,请查收并点击链接完成注册。'
|
||||
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="no-underline">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky">重发验证邮件</NButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--teal">去登录</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
|
||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">注册后,请先通过邮件确认邮箱再登录。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleRegister">
|
||||
<NFormItem label="用户名">
|
||||
<NInput v-model:value="name" maxlength="16" show-count placeholder="最多 16 个字符" :input-props="{ autocomplete: 'username', name: 'username', required: true }" />
|
||||
</NFormItem>
|
||||
<NFormItem label="邮箱">
|
||||
<NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 位" :input-props="{ autocomplete: 'new-password', name: 'new-password', required: true }" />
|
||||
</NFormItem>
|
||||
<NFormItem label="确认密码">
|
||||
<NInput v-model:value="confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" :input-props="{ autocomplete: 'new-password', name: 'confirm-new-password', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
<div v-if="canResend" class="mt-2">
|
||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<p class="mt-6 text-sm text-slate-500">已有账号? <RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink></p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const cooldown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const buttonLabel = computed(() => loading.value ? '发送中...' : cooldown.value > 0 ? `重新发送(${cooldown.value}s)` : '重新发送')
|
||||
|
||||
function startCooldown() {
|
||||
if (timer) clearInterval(timer)
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
const value = email.value.trim()
|
||||
if (!value || value.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.resendConfirmation(value)
|
||||
email.value = value
|
||||
submitted.value = true
|
||||
startCooldown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '验证邮件发送失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="submitted" status="success" title="请求已受理" description="如果该邮箱符合条件,我们会发送新的验证邮件。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky" :disabled="cooldown > 0" :loading="loading" @click="submit">{{ buttonLabel }}</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--teal">返回登录</NButton></RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Email Verification</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">重发验证邮件</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">输入注册邮箱。为保护账号,系统不会透露邮箱是否存在或已验证。</p>
|
||||
</div>
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="邮箱"><NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" /></NFormItem>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">{{ loading ? '发送中...' : '发送验证邮件' }}</NButton>
|
||||
</NForm>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const email = typeof route.query.email === 'string' ? route.query.email : ''
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
||||
const invalid = ref(!email || !token)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
const countdown = ref(5)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
if (route.query.token !== undefined || route.query.email !== undefined) {
|
||||
window.history.replaceState(window.history.state, '', route.path)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => !invalid.value && password.value.length >= 8 && password.value === confirmPassword.value)
|
||||
|
||||
function startCountdown() {
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
if (timer) clearInterval(timer)
|
||||
void router.push({ path: '/login', query: { email } })
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (password.value.length < 8) {
|
||||
error.value = '新密码至少需要 8 位'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.resetPassword(email, token, password.value)
|
||||
success.value = true
|
||||
startCountdown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
||||
if (error.value.includes('无效') || error.value.includes('过期')) invalid.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnavailableAuthView />
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="success" status="success" title="密码已重置" description="请使用新密码重新登录。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--teal" @click="router.push({ path: '/login', query: { email } })">立即登录</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">提交成功后,此重置链接和现有登录会话都会失效。</p>
|
||||
</div>
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="新密码"><NInput v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 位" :disabled="invalid" :input-props="{ autocomplete: 'new-password', name: 'new-password', required: true }" /></NFormItem>
|
||||
<NFormItem label="确认新密码"><NInput v-model:value="confirmPassword" type="password" show-password-on="click" placeholder="再次输入新密码" :disabled="invalid" :input-props="{ autocomplete: 'new-password', name: 'confirm-new-password', required: true }" /></NFormItem>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<RouterLink v-if="invalid" :to="{ path: '/forgot-password', query: { email } }" class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800">重新发送密码重置邮件</RouterLink>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--teal" :disabled="!canSubmit" :loading="loading">保存新密码</NButton>
|
||||
</NForm>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { NButton, NCard, NResult } from 'naive-ui'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const copy = computed(() => {
|
||||
if (route.name === 'register') {
|
||||
return {
|
||||
title: '注册暂未开放',
|
||||
description: '新后端的账号激活流程尚未完成。为避免创建无法登录的账号,前端暂时关闭注册。',
|
||||
}
|
||||
}
|
||||
if (route.name === 'auth-confirm') {
|
||||
return {
|
||||
title: '邮箱确认暂不可用',
|
||||
description: '新后端尚未实现邮箱确认接口,请等待服务恢复后再使用确认链接。',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: '密码找回暂不可用',
|
||||
description: '新后端尚未实现忘记密码与重置密码接口。如需恢复账号,请联系管理员。',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult status="info" :title="copy.title" :description="copy.description">
|
||||
<template #footer>
|
||||
<RouterLink to="/login" class="no-underline">
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--teal">
|
||||
返回登录
|
||||
</NButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</NResult>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user