mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
merge: resolve conflicts after merging origin/main
- Remove duplicate useSubmitForReview/useConfirmPublish in hooks and client - Restore resolveVersionStatusLabel (version-status-badge not in this branch) - Fix missing Card closing tag in versions tab
This commit is contained in:
commit
b597a6f511
57 changed files with 2221 additions and 94 deletions
|
|
@ -80,3 +80,16 @@ DEVICE_AUTH_VERIFICATION_URI=
|
|||
# Leave both empty if you are not enabling GitHub login yet.
|
||||
OAUTH2_GITHUB_CLIENT_ID=
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# SMTP configuration for password reset verification emails.
|
||||
SPRING_MAIL_HOST=smtp.example.com
|
||||
SPRING_MAIL_PORT=587
|
||||
SPRING_MAIL_USERNAME=TODO_fill_smtp_username
|
||||
SPRING_MAIL_PASSWORD=TODO_fill_smtp_password
|
||||
SPRING_MAIL_SMTP_AUTH=true
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ DEVICE_AUTH_VERIFICATION_URI=
|
|||
OAUTH2_GITHUB_CLIENT_ID=
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# SMTP configuration for password reset verification emails.
|
||||
SPRING_MAIL_HOST=
|
||||
SPRING_MAIL_PORT=587
|
||||
SPRING_MAIL_USERNAME=
|
||||
SPRING_MAIL_PASSWORD=
|
||||
SPRING_MAIL_SMTP_AUTH=true
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
||||
|
||||
# Security scanner is enabled by default. Set to false to disable scanning.
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=true
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -95,7 +95,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan
|
|||
**For users in China (Aliyun mirror):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
If deployment runs into problems, clear the existing runtime home and retry.
|
||||
|
|
@ -195,7 +195,7 @@ Published images target both `linux/amd64` and `linux/arm64`.
|
|||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
|
||||
|
||||
# Aliyun mirror (recommended for users in China)
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
**Deployment parameters:**
|
||||
|
|
@ -397,10 +397,16 @@ npx clawhub search email
|
|||
npx clawhub install my-skill
|
||||
npx clawhub install my-namespace--my-skill
|
||||
|
||||
# Publish a skill
|
||||
npx clawhub publish ./my-skill
|
||||
# Publish to global namespace
|
||||
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
|
||||
|
||||
# Publish to a team namespace such as my-space
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
`my-space--my-skill` is the canonical compat slug. SkillHub parses it as
|
||||
namespace `my-space` plus skill slug `my-skill`.
|
||||
|
||||
> 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill`
|
||||
|
||||
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
|
||||
|
|
|
|||
14
README_zh.md
14
README_zh.md
|
|
@ -67,7 +67,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
|
|||
**国内用户(阿里云镜像):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
如果部署遇到问题,请清除现有的运行时目录并重试。
|
||||
|
|
@ -177,7 +177,7 @@ skillhub/
|
|||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
|
||||
|
||||
# 阿里云镜像(国内推荐)
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
### 配置参数说明
|
||||
|
|
@ -331,10 +331,16 @@ npx clawhub search email
|
|||
npx clawhub install my-skill
|
||||
npx clawhub install my-namespace--my-skill
|
||||
|
||||
# 发布技能
|
||||
npx clawhub publish ./my-skill
|
||||
# 发布到 global 空间
|
||||
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
|
||||
|
||||
# 发布到如 my-space 这样的团队空间
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
其中 `my-space--my-skill` 是兼容层使用的 canonical slug,SkillHub 会将其解析为
|
||||
namespace `my-space` 和 skill slug `my-skill`。
|
||||
|
||||
> 💡 **提示**:上述命令不仅适用于 OpenClaw,通过指定安装目录(`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill`
|
||||
|
||||
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@ services:
|
|||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
|
||||
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
|
||||
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}
|
||||
SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-}
|
||||
SPRING_MAIL_SMTP_AUTH: ${SPRING_MAIL_SMTP_AUTH:-false}
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:-false}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE:-false}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:-}
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:-PT10M}
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:-noreply@skillhub.local}
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:-SkillHub}
|
||||
volumes:
|
||||
- skillhub_storage:/var/lib/skillhub/storage
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
|
|||
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
|
||||
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
|
||||
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
|
||||
|
||||
## 8 裸金属上线清单
|
||||
|
||||
|
|
|
|||
317
docs/19-smtp-password-reset-email-setup.md
Normal file
317
docs/19-smtp-password-reset-email-setup.md
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
# SkillHub SMTP 邮箱配置指南(验证码邮件)
|
||||
|
||||
本文说明如何为 SkillHub 配置 SMTP,用于发送“密码重置验证码”邮件。
|
||||
|
||||
适用场景:
|
||||
- 生产/预发布环境(`compose.release.yml` + `.env.release`)
|
||||
- 本地联调环境(直接注入后端环境变量)
|
||||
|
||||
补充说明:
|
||||
- SMTP 本质是邮件传输协议,不是单一厂商产品。
|
||||
- 你可以使用企业邮箱、云邮箱或本地测试 SMTP 服务(例如 MailHog)作为 SMTP 服务端。
|
||||
|
||||
当前密码重置页面入口说明:
|
||||
- 当前前端统一使用 `/reset-password` 页面。
|
||||
- 该页面同时包含“发送验证码”和“提交新密码”两步,不再单独使用 `/forgot-password`。
|
||||
|
||||
## 1. 需要配置的环境变量
|
||||
|
||||
以下变量已被后端读取:
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
|---|---|---|
|
||||
| `SPRING_MAIL_HOST` | SMTP 服务器地址 | `smtp.example.com` |
|
||||
| `SPRING_MAIL_PORT` | SMTP 端口 | `465` |
|
||||
| `SPRING_MAIL_USERNAME` | SMTP 用户名 | `noreply@example.com` |
|
||||
| `SPRING_MAIL_PASSWORD` | SMTP 密码/授权码 | `xxxxxx` |
|
||||
| `SPRING_MAIL_SMTP_AUTH` | 是否启用 SMTP AUTH | `true` |
|
||||
| `SPRING_MAIL_SMTP_STARTTLS_ENABLE` | 是否启用 STARTTLS | `false` |
|
||||
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE` | 是否启用 SMTP SSL 直连 | `true` |
|
||||
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST` | SSL 信任主机(用于规避部分环境下证书链校验失败) | `smtp.mail.example` |
|
||||
| `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` | 验证码有效期(ISO-8601 Duration) | `PT10M` |
|
||||
| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` | 发件人邮箱 | `noreply@example.com` |
|
||||
| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME` | 发件人名称 | `SkillHub` |
|
||||
|
||||
说明:
|
||||
- 当前文档统一按 `465 + SSL` 配置,不再展开 `587 + STARTTLS` 方案。
|
||||
- 使用 `465` 时配置:`STARTTLS=false`、`SSL_ENABLE=true`。
|
||||
- 若出现 `PKIX path building failed` / `SSLHandshakeException`,可尝试增加 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=<SMTP_HOST>`(本地联调常用)。
|
||||
- 生产环境默认不建议配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,仅在证书链异常时临时启用。
|
||||
- `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` 支持如 `PT5M`、`PT10M`、`PT30M`。
|
||||
|
||||
## 1.1 配置方案速查(推荐)
|
||||
|
||||
### A. 通用 SMTP 邮箱(本地直连真实邮箱)
|
||||
|
||||
```dotenv
|
||||
SPRING_MAIL_HOST=smtp.mail.example
|
||||
SPRING_MAIL_PORT=465
|
||||
SPRING_MAIL_USERNAME=mailer@example.com
|
||||
SPRING_MAIL_PASSWORD=your-smtp-app-password
|
||||
SPRING_MAIL_SMTP_AUTH=true
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
|
||||
```
|
||||
|
||||
本地 `export` 示例写法:
|
||||
|
||||
```bash
|
||||
export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
|
||||
export SPRING_MAIL_HOST=smtp.mail.example
|
||||
export SPRING_MAIL_PORT=465
|
||||
export SPRING_MAIL_USERNAME=mailer@example.com
|
||||
export SPRING_MAIL_PASSWORD=your-smtp-app-password
|
||||
export SPRING_MAIL_SMTP_AUTH=true
|
||||
export SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
|
||||
export SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
export SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
|
||||
export SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
|
||||
```
|
||||
|
||||
### B. MailHog(本地联调推荐)
|
||||
|
||||
```dotenv
|
||||
SPRING_MAIL_HOST=127.0.0.1
|
||||
SPRING_MAIL_PORT=1025
|
||||
SPRING_MAIL_USERNAME=
|
||||
SPRING_MAIL_PASSWORD=
|
||||
SPRING_MAIL_SMTP_AUTH=false
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
||||
```
|
||||
|
||||
### C. 线上部署(465 端口示例)
|
||||
|
||||
```dotenv
|
||||
SPRING_MAIL_HOST=smtp.mail.example
|
||||
SPRING_MAIL_PORT=465
|
||||
SPRING_MAIL_USERNAME=mailer@example.com
|
||||
SPRING_MAIL_PASSWORD=your-smtp-app-password
|
||||
SPRING_MAIL_SMTP_AUTH=true
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
|
||||
```
|
||||
|
||||
## 2. 单机交付(Compose)配置步骤
|
||||
|
||||
1. 复制环境模板(若尚未创建):
|
||||
|
||||
```bash
|
||||
cp .env.release.example .env.release
|
||||
```
|
||||
|
||||
2. 编辑 `.env.release`,填写 SMTP 变量:
|
||||
|
||||
```dotenv
|
||||
SPRING_MAIL_HOST=smtp.mail.example
|
||||
SPRING_MAIL_PORT=465
|
||||
SPRING_MAIL_USERNAME=mailer@example.com
|
||||
SPRING_MAIL_PASSWORD=your-smtp-app-password
|
||||
SPRING_MAIL_SMTP_AUTH=true
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
|
||||
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
|
||||
```
|
||||
|
||||
3. 重启后端容器使配置生效:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d server
|
||||
```
|
||||
|
||||
4. 查看后端日志确认启动正常:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml logs -f server
|
||||
```
|
||||
|
||||
## 3. 本地开发配置与验证
|
||||
|
||||
### 3.1 一次性临时生效(推荐)
|
||||
|
||||
适合当前终端临时测试,重开终端后失效。
|
||||
|
||||
```bash
|
||||
SPRING_MAIL_HOST=smtp.mail.example \
|
||||
SPRING_MAIL_PORT=465 \
|
||||
SPRING_MAIL_USERNAME=mailer@example.com \
|
||||
SPRING_MAIL_PASSWORD=your-smtp-app-password \
|
||||
SPRING_MAIL_SMTP_AUTH=true \
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true \
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example \
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M \
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com \
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name \
|
||||
make dev-server
|
||||
```
|
||||
|
||||
### 3.2 长期生效(shell 配置)
|
||||
|
||||
如果你写到了 `~/.zshrc`,请注意:
|
||||
- 必须 `source ~/.zshrc` 或重开终端后变量才会生效
|
||||
- 需要在“同一个终端”启动 `make dev-server`
|
||||
|
||||
可先确认变量是否在当前 shell 中:
|
||||
|
||||
```bash
|
||||
env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)'
|
||||
```
|
||||
|
||||
### 3.3 推荐联调方式(MailHog)
|
||||
|
||||
如果你只是本地验证验证码链路,建议用 MailHog 作为本地 SMTP 服务:
|
||||
|
||||
1. 启动 MailHog:
|
||||
|
||||
```bash
|
||||
docker run -d --name skillhub-mailhog \
|
||||
-p 1025:1025 \
|
||||
-p 8025:8025 \
|
||||
mailhog/mailhog
|
||||
```
|
||||
|
||||
2. 启动依赖服务(Postgres/Redis):
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
3. 启动后端时注入 SMTP 环境变量(示例):
|
||||
|
||||
```bash
|
||||
SPRING_MAIL_HOST=127.0.0.1 \
|
||||
SPRING_MAIL_PORT=1025 \
|
||||
SPRING_MAIL_USERNAME= \
|
||||
SPRING_MAIL_PASSWORD= \
|
||||
SPRING_MAIL_SMTP_AUTH=false \
|
||||
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false \
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local \
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub \
|
||||
make dev-server
|
||||
```
|
||||
|
||||
4. 打开 MailHog Web UI 查看邮件:
|
||||
|
||||
```text
|
||||
http://localhost:8025
|
||||
```
|
||||
|
||||
5. 在 SkillHub 页面验证流程:
|
||||
- 打开 `/reset-password`
|
||||
- 输入邮箱并点击“发送验证码”
|
||||
- 在 MailHog 中查看验证码邮件
|
||||
- 输入邮箱 + 验证码 + 新密码完成重置
|
||||
|
||||
6. 也可使用接口做快速验证(示例):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/local/password-reset/request \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"your-email@example.com"}'
|
||||
```
|
||||
|
||||
## 4. 功能验证(验证码邮件)
|
||||
|
||||
### 4.1 用户自助找回
|
||||
|
||||
在 `/reset-password` 页面点击“发送验证码”后,系统会尝试发送验证码邮件。
|
||||
|
||||
说明:
|
||||
- 为防止账号枚举,自助接口总是返回通用成功提示。
|
||||
- 即使邮件发送失败,接口也可能返回成功;请结合后端日志确认实际发送结果。
|
||||
|
||||
### 4.2 管理员触发重置
|
||||
|
||||
管理员在用户管理页触发“重置密码”时,系统会强制发送验证码;
|
||||
若 SMTP 发送失败,会返回错误(便于运维排障)。
|
||||
|
||||
## 5. 常见问题排查
|
||||
|
||||
### 5.1 认证失败(`535 Authentication failed`)
|
||||
|
||||
排查方向:
|
||||
- 用户名/密码是否正确
|
||||
- 邮箱服务是否要求“客户端授权码”而非登录密码
|
||||
- 发件账号是否已开启 SMTP 服务
|
||||
|
||||
### 5.2 连接超时或拒绝连接
|
||||
|
||||
排查方向:
|
||||
- 主机到 SMTP 服务端口 `465` 是否可达
|
||||
- 安全组/防火墙是否放行出站连接
|
||||
- SMTP 服务地址是否填写正确
|
||||
|
||||
### 5.3 本地明明配置了变量但不生效
|
||||
|
||||
排查方向:
|
||||
- 是否只是编辑了 `~/.zshrc` 但没有 `source ~/.zshrc`
|
||||
- 启动后端的终端是否与配置变量的终端是同一个
|
||||
- `8080` 是否被旧进程占用,导致新进程没启动成功
|
||||
|
||||
可执行以下命令快速检查:
|
||||
|
||||
```bash
|
||||
# 查看 8080 是否被旧进程占用
|
||||
lsof -nP -iTCP:8080 -sTCP:LISTEN
|
||||
|
||||
# 查看当前 shell 是否有 SMTP 环境变量
|
||||
env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)'
|
||||
```
|
||||
|
||||
### 5.4 发件人被拒绝
|
||||
|
||||
排查方向:
|
||||
- `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` 是否与 SMTP 账号一致或已验证
|
||||
- 邮箱服务是否限制别名发件
|
||||
|
||||
### 5.5 健康检查是否校验 SMTP
|
||||
|
||||
默认配置下,邮件健康检查关闭,不会因为 SMTP 不可达导致 `health` 失败。
|
||||
|
||||
若需要将 SMTP 连通性纳入健康检查,可设置:
|
||||
|
||||
```dotenv
|
||||
MANAGEMENT_HEALTH_MAIL_ENABLED=true
|
||||
```
|
||||
|
||||
### 5.6 SMTP 报 `PKIX path building failed`(证书链校验失败)
|
||||
|
||||
典型日志:
|
||||
- `SSLHandshakeException`
|
||||
- `unable to find valid certification path to requested target`
|
||||
|
||||
处理建议(本地联调):
|
||||
- 增加:
|
||||
|
||||
```dotenv
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
|
||||
```
|
||||
|
||||
- 然后重启后端,再触发一次“发送验证码”。
|
||||
|
||||
补充:
|
||||
- 该配置用于指定信任主机,适合本地排障与联调。
|
||||
- 生产环境默认不建议长期启用该配置,更推荐使用规范 CA 证书链或将企业 CA 导入 Java truststore。
|
||||
|
||||
## 6. 安全建议
|
||||
|
||||
- 不要把 SMTP 密码提交到仓库;仅写入受控的 `.env.release` 或密钥管理系统。
|
||||
- 使用专用发信账号,避免使用个人邮箱主密码。
|
||||
- 生产环境建议定期轮换 SMTP 授权码。
|
||||
|
|
@ -110,8 +110,11 @@ npx clawhub list --help
|
|||
### 5. Publish Skills
|
||||
|
||||
```bash
|
||||
# Publish skill (requires appropriate permissions)
|
||||
# Publish to the global namespace (requires appropriate permissions)
|
||||
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
|
||||
|
||||
# Publish to a team namespace such as my-space
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
|
||||
npx clawhub sync --all # Upload all skills in current folder
|
||||
|
||||
# Help
|
||||
|
|
@ -119,6 +122,10 @@ npx clawhub publish --help
|
|||
npx clawhub sync --help
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill`
|
||||
- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix
|
||||
|
||||
## API Endpoints
|
||||
|
||||
SkillHub compatibility layer provides the following endpoints:
|
||||
|
|
|
|||
|
|
@ -110,8 +110,11 @@ npx clawhub list --help
|
|||
### 5. 发布技能
|
||||
|
||||
```bash
|
||||
# 发布技能(需要相应权限)
|
||||
# 发布到 global 空间(需要相应权限)
|
||||
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
|
||||
|
||||
# 发布到如 my-space 这样的团队空间
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
|
||||
npx clawhub sync --all # 上传当前文件夹中所有的 skill
|
||||
|
||||
# 使用帮助
|
||||
|
|
@ -119,6 +122,10 @@ npx clawhub publish --help
|
|||
npx clawhub sync --help
|
||||
```
|
||||
|
||||
说明:
|
||||
- `my-space--my-skill` 是兼容层 canonical slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
|
||||
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
|
||||
|
||||
## API 端点说明
|
||||
|
||||
SkillHub 兼容层提供以下端点:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +38,8 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
@Service
|
||||
public class ClawHubCompatAppService {
|
||||
|
||||
private static final String GLOBAL_NAMESPACE = "global";
|
||||
|
||||
private final CanonicalSlugMapper mapper;
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
private final SkillQueryService skillQueryService;
|
||||
|
|
@ -268,7 +271,7 @@ public class ClawHubCompatAppService {
|
|||
String clientIp,
|
||||
String userAgent) throws IOException {
|
||||
MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson);
|
||||
String namespace = determineNamespace(principal, extracted.payload());
|
||||
String namespace = determineNamespace(extracted.payload());
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
namespace,
|
||||
extracted.entries(),
|
||||
|
|
@ -371,8 +374,28 @@ public class ClawHubCompatAppService {
|
|||
);
|
||||
}
|
||||
|
||||
private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) {
|
||||
return "global";
|
||||
private String determineNamespace(MultipartPackageExtractor.PublishPayload payload) {
|
||||
if (payload == null) {
|
||||
return GLOBAL_NAMESPACE;
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(payload.namespace())) {
|
||||
return normalizeNamespace(payload.namespace());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(payload.slug()) && payload.slug().contains("--")) {
|
||||
return mapper.fromCanonical(payload.slug()).namespace();
|
||||
}
|
||||
|
||||
return GLOBAL_NAMESPACE;
|
||||
}
|
||||
|
||||
private String normalizeNamespace(String namespace) {
|
||||
String trimmed = namespace.trim();
|
||||
if (trimmed.startsWith("@")) {
|
||||
return trimmed.substring(1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private void recordCompatPublishAudit(String userId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.local.PasswordResetService;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
|
|
@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.AuthMeResponse;
|
|||
import com.iflytek.skillhub.dto.ChangePasswordRequest;
|
||||
import com.iflytek.skillhub.dto.LocalLoginRequest;
|
||||
import com.iflytek.skillhub.dto.LocalRegisterRequest;
|
||||
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
|
||||
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
|
|
@ -34,17 +37,20 @@ public class LocalAuthController extends BaseApiController {
|
|||
private final SkillHubMetrics skillHubMetrics;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final AuthFailureThrottleService authFailureThrottleService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
public LocalAuthController(ApiResponseFactory responseFactory,
|
||||
LocalAuthService localAuthService,
|
||||
SkillHubMetrics skillHubMetrics,
|
||||
PlatformSessionService platformSessionService,
|
||||
AuthFailureThrottleService authFailureThrottleService) {
|
||||
AuthFailureThrottleService authFailureThrottleService,
|
||||
PasswordResetService passwordResetService) {
|
||||
super(responseFactory);
|
||||
this.localAuthService = localAuthService;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
this.platformSessionService = platformSessionService;
|
||||
this.authFailureThrottleService = authFailureThrottleService;
|
||||
this.passwordResetService = passwordResetService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
|
|
@ -92,6 +98,20 @@ public class LocalAuthController extends BaseApiController {
|
|||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
@PostMapping("/password-reset/request")
|
||||
@RateLimit(category = "auth-password-reset-request", authenticated = 8, anonymous = 5, windowSeconds = 300)
|
||||
public ApiResponse<Void> requestPasswordReset(@Valid @RequestBody PasswordResetRequestDto request) {
|
||||
passwordResetService.requestPasswordReset(request.email());
|
||||
return ok("response.auth.password.reset.requested", null);
|
||||
}
|
||||
|
||||
@PostMapping("/password-reset/confirm")
|
||||
@RateLimit(category = "auth-password-reset-confirm", authenticated = 10, anonymous = 10, windowSeconds = 300)
|
||||
public ApiResponse<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) {
|
||||
passwordResetService.confirmPasswordReset(request.email(), request.code(), request.newPassword());
|
||||
return ok("response.auth.password.reset.confirmed", null);
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.auth.local.PasswordResetService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
|
||||
import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest;
|
||||
|
|
@ -9,6 +10,7 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
|
|||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.service.AdminUserAppService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
|
@ -24,11 +26,14 @@ import org.springframework.web.bind.annotation.*;
|
|||
public class UserManagementController extends BaseApiController {
|
||||
|
||||
private final AdminUserAppService adminUserAppService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
public UserManagementController(AdminUserAppService adminUserAppService,
|
||||
PasswordResetService passwordResetService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.adminUserAppService = adminUserAppService;
|
||||
this.passwordResetService = passwordResetService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -76,4 +81,15 @@ public class UserManagementController extends BaseApiController {
|
|||
public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/password-reset")
|
||||
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<Void> triggerPasswordReset(@PathVariable String userId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
passwordResetService.adminTriggerPasswordReset(userId, principal.userId());
|
||||
return ok("response.auth.password.reset.requested", null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class MultipartPackageExtractor {
|
|||
}
|
||||
|
||||
public record PublishPayload(
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ public record LocalRegisterRequest(
|
|||
String username,
|
||||
@NotBlank(message = "{validation.auth.local.password.notBlank}")
|
||||
String password,
|
||||
@NotBlank(message = "{validation.auth.local.email.notBlank}")
|
||||
@Email(message = "{validation.auth.local.email.invalid}")
|
||||
String email
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
public record PasswordResetConfirmRequest(
|
||||
@NotBlank(message = "{validation.auth.password.reset.email.notBlank}")
|
||||
@Email(message = "{validation.auth.password.reset.email.invalid}")
|
||||
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}")
|
||||
String email,
|
||||
@NotBlank(message = "{validation.auth.password.reset.code.notBlank}")
|
||||
@Pattern(regexp = "^\\d{6}$", message = "{validation.auth.password.reset.code.invalid}")
|
||||
String code,
|
||||
@NotBlank(message = "{validation.auth.password.reset.newPassword.notBlank}")
|
||||
String newPassword
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
public record PasswordResetRequestDto(
|
||||
@NotBlank(message = "{validation.auth.password.reset.email.notBlank}")
|
||||
@Email(message = "{validation.auth.password.reset.email.invalid}")
|
||||
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}")
|
||||
String email
|
||||
) {
|
||||
}
|
||||
|
|
@ -61,6 +61,17 @@ spring:
|
|||
multipart:
|
||||
max-file-size: 100MB
|
||||
max-request-size: 100MB
|
||||
mail:
|
||||
host: ${SPRING_MAIL_HOST:localhost}
|
||||
port: ${SPRING_MAIL_PORT:25}
|
||||
username: ${SPRING_MAIL_USERNAME:}
|
||||
password: ${SPRING_MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: ${SPRING_MAIL_SMTP_AUTH:false}
|
||||
starttls:
|
||||
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
|
||||
|
||||
skillhub:
|
||||
auth:
|
||||
|
|
@ -70,6 +81,10 @@ skillhub:
|
|||
enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false}
|
||||
session-bootstrap:
|
||||
enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false}
|
||||
password-reset:
|
||||
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
|
||||
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
|
||||
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
|
|
@ -168,6 +183,9 @@ skillhub:
|
|||
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
|
||||
|
||||
management:
|
||||
health:
|
||||
mail:
|
||||
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
-- Password reset verification code records for self-service and admin-triggered flows
|
||||
CREATE TABLE password_reset_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
code_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
requested_by_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
requested_by_user_id VARCHAR(128) REFERENCES user_account(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_password_reset_request_user_id ON password_reset_request(user_id);
|
||||
CREATE INDEX idx_password_reset_request_expires_at ON password_reset_request(expires_at);
|
||||
|
||||
COMMENT ON TABLE password_reset_request IS 'Stores password reset verification code requests for local account recovery';
|
||||
COMMENT ON COLUMN password_reset_request.code_hash IS 'BCrypt hash of the one-time verification code';
|
||||
COMMENT ON COLUMN password_reset_request.requested_by_admin IS 'True when the reset is triggered by an administrator';
|
||||
COMMENT ON COLUMN password_reset_request.requested_by_user_id IS 'Admin user who triggered the reset, if applicable';
|
||||
|
|
@ -16,6 +16,7 @@ validation.member.userId.notNull=User ID is required
|
|||
validation.member.role.notNull=Role is required
|
||||
validation.auth.local.username.notBlank=Username cannot be blank
|
||||
validation.auth.local.password.notBlank=Password cannot be blank
|
||||
validation.auth.local.email.notBlank=Email cannot be blank
|
||||
validation.auth.local.currentPassword.notBlank=Current password cannot be blank
|
||||
validation.auth.local.newPassword.notBlank=New password cannot be blank
|
||||
validation.auth.local.email.invalid=Email format is invalid
|
||||
|
|
@ -153,3 +154,16 @@ error.profileReview.commentRequired=Rejection reason is required
|
|||
error.profileReview.commentTooLong=Rejection reason must not exceed 500 characters
|
||||
error.profileReview.status.invalid=Invalid review status: {0}
|
||||
error.profileReview.userDisabled=Cannot apply changes — user account is disabled
|
||||
|
||||
# Password reset
|
||||
response.auth.password.reset.requested=If the account is eligible, a password reset verification code has been sent.
|
||||
response.auth.password.reset.confirmed=Password has been reset successfully. Please sign in with your new password.
|
||||
error.auth.password.reset.invalid.code=The verification code is invalid or has expired.
|
||||
error.auth.password.reset.not.eligible=This account is not eligible for password reset.
|
||||
error.auth.password.reset.no.credential=This account does not have a local credential.
|
||||
error.auth.password.reset.email.failed=Failed to send password reset verification code. Please try again later.
|
||||
validation.auth.password.reset.email.notBlank=Email cannot be blank
|
||||
validation.auth.password.reset.email.invalid=Email format is invalid
|
||||
validation.auth.password.reset.code.notBlank=Verification code cannot be blank
|
||||
validation.auth.password.reset.code.invalid=Verification code must be 6 digits
|
||||
validation.auth.password.reset.newPassword.notBlank=New password cannot be blank
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ validation.member.userId.notNull=用户 ID 不能为空
|
|||
validation.member.role.notNull=角色不能为空
|
||||
validation.auth.local.username.notBlank=用户名不能为空
|
||||
validation.auth.local.password.notBlank=密码不能为空
|
||||
validation.auth.local.email.notBlank=邮箱不能为空
|
||||
validation.auth.local.currentPassword.notBlank=当前密码不能为空
|
||||
validation.auth.local.newPassword.notBlank=新密码不能为空
|
||||
validation.auth.local.email.invalid=邮箱格式不正确
|
||||
|
|
@ -153,3 +154,16 @@ error.profileReview.commentRequired=拒绝原因不能为空
|
|||
error.profileReview.commentTooLong=拒绝原因不能超过 500 个字符
|
||||
error.profileReview.status.invalid=无效的审核状态:{0}
|
||||
error.profileReview.userDisabled=无法应用变更——用户账号已被禁用
|
||||
|
||||
# Password reset
|
||||
response.auth.password.reset.requested=如果账号符合条件,密码重置验证码已发送。
|
||||
response.auth.password.reset.confirmed=密码已重置成功,请使用新密码登录。
|
||||
error.auth.password.reset.invalid.code=验证码无效或已过期。
|
||||
error.auth.password.reset.not.eligible=该账号不符合密码重置条件。
|
||||
error.auth.password.reset.no.credential=该账号没有本地凭证。
|
||||
error.auth.password.reset.email.failed=发送密码重置验证码失败,请稍后重试。
|
||||
validation.auth.password.reset.email.notBlank=邮箱不能为空
|
||||
validation.auth.password.reset.email.invalid=邮箱格式不正确
|
||||
validation.auth.password.reset.code.notBlank=验证码不能为空
|
||||
validation.auth.password.reset.code.invalid=验证码必须为 6 位数字
|
||||
validation.auth.password.reset.newPassword.notBlank=新密码不能为空
|
||||
|
|
|
|||
|
|
@ -2,35 +2,46 @@ package com.iflytek.skillhub.compat;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -56,6 +67,12 @@ class ClawHubCompatControllerTest {
|
|||
@MockBean
|
||||
private CompatSkillLookupService compatSkillLookupService;
|
||||
|
||||
@MockBean
|
||||
private SkillPublishService skillPublishService;
|
||||
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Test
|
||||
void search_returns_mapped_results() throws Exception {
|
||||
when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
|
||||
|
|
@ -204,9 +221,122 @@ class ClawHubCompatControllerTest {
|
|||
.andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_canonical_slug_routes_to_namespace_publish() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 34L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("team-ai"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(12L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"slug":"team-ai--my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("12"))
|
||||
.andExpect(jsonPath("$.versionId").value("34"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_plain_slug_defaults_to_global_namespace() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 35L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("global"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(13L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("13"))
|
||||
.andExpect(jsonPath("$.versionId").value("35"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_payload_namespace_uses_explicit_namespace() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 36L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("team-explicit"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(14L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"namespace":"@team-explicit","slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("14"))
|
||||
.andExpect(jsonPath("$.versionId").value("36"));
|
||||
}
|
||||
|
||||
private CompatSkillLookupService.CompatSkillContext legacyCompatContext(String namespaceSlug, String skillSlug) {
|
||||
Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, "tester");
|
||||
Skill skill = new Skill(1L, skillSlug, "tester", SkillVisibility.PUBLIC);
|
||||
return new CompatSkillLookupService.CompatSkillContext(namespace, skill, Optional.empty());
|
||||
}
|
||||
|
||||
private MockMultipartFile skillMdFile() {
|
||||
return new MockMultipartFile(
|
||||
"files",
|
||||
"SKILL.md",
|
||||
"text/markdown",
|
||||
"""
|
||||
---
|
||||
name: my-skill
|
||||
description: Demo skill
|
||||
version: 1.0.0
|
||||
---
|
||||
""".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
private SkillVersion publishVersion(String versionValue, long versionId) {
|
||||
SkillVersion version = new SkillVersion(12L, versionValue, "user-42");
|
||||
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
ReflectionTestUtils.setField(version, "id", versionId);
|
||||
return version;
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken superAdminAuth() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.local.PasswordResetService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
|
|
@ -50,6 +51,9 @@ class LocalAuthControllerTest {
|
|||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@MockBean
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@Test
|
||||
void login_returnsCurrentUserEnvelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
|
|
@ -119,6 +123,23 @@ class LocalAuthControllerTest {
|
|||
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_rejectsBlankEmail() throws Exception {
|
||||
given(localAuthService.register("bob", "Abcd123!", " "))
|
||||
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/register")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"bob","password":"Abcd123!","email":" "}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
|
||||
verify(localAuthService).register("bob", "Abcd123!", " ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_failure_recordsFailureMetric() throws Exception {
|
||||
given(localAuthService.login("alice", "wrong"))
|
||||
|
|
@ -176,4 +197,66 @@ class LocalAuthControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"alice@example.com"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(passwordResetService).requestPasswordReset("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception {
|
||||
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
|
||||
.given(passwordResetService).requestPasswordReset("alice");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"alice"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
|
||||
verify(passwordResetService).requestPasswordReset("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPasswordReset_returnsUpdatedEnvelope() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"alice@example.com","code":"123456","newPassword":"Abcd123!"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(passwordResetService).confirmPasswordReset("alice@example.com", "123456", "Abcd123!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception {
|
||||
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
|
||||
.given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"alice","code":"123456","newPassword":"Abcd123!"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
|
||||
verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@ package com.iflytek.skillhub.controller;
|
|||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
|
||||
import com.iflytek.skillhub.service.NamespacePortalCommandAppService;
|
||||
import com.iflytek.skillhub.service.NamespacePortalQueryAppService;
|
||||
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -28,7 +29,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
|
@ -52,25 +52,19 @@ class NamespaceWorkflowContractTest {
|
|||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceService namespaceService;
|
||||
private NamespacePortalCommandAppService namespacePortalCommandAppService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceGovernanceService namespaceGovernanceService;
|
||||
private NamespacePortalQueryAppService namespacePortalQueryAppService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberService namespaceMemberService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
private GovernanceWorkflowAppService governanceWorkflowAppService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
@MockBean
|
||||
private UserAccountRepository userAccountRepository;
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
|
@ -82,24 +76,30 @@ class NamespaceWorkflowContractTest {
|
|||
Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
|
||||
NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN);
|
||||
setMemberId(adminMember, 11L);
|
||||
NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace);
|
||||
NamespaceResponse frozenResponse = NamespaceResponse.from(frozen);
|
||||
NamespaceResponse archivedResponse = NamespaceResponse.from(archived);
|
||||
MemberResponse adminMemberResponse = MemberResponse.from(
|
||||
adminMember,
|
||||
new UserAccount("user-admin", "Admin", "admin@example.com", null)
|
||||
);
|
||||
|
||||
given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1")))
|
||||
.willReturn(namespace);
|
||||
given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace);
|
||||
given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any()))
|
||||
.willReturn(frozen);
|
||||
given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any()))
|
||||
.willReturn(archived);
|
||||
given(namespacePortalCommandAppService.createNamespace(any(), any()))
|
||||
.willReturn(namespaceResponse);
|
||||
given(governanceWorkflowAppService.freezeNamespace(eq("team-flow"), any(), eq("owner-1"), any()))
|
||||
.willReturn(frozenResponse);
|
||||
given(governanceWorkflowAppService.archiveNamespace(eq("team-flow"), any(), eq("owner-1"), any()))
|
||||
.willReturn(archivedResponse);
|
||||
given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10))
|
||||
.willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE")));
|
||||
given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1"))
|
||||
.willReturn(adminMember);
|
||||
given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class)))
|
||||
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember)));
|
||||
given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1"))
|
||||
.willReturn(adminMember);
|
||||
given(userAccountRepository.findById("user-admin"))
|
||||
.willReturn(Optional.of(new UserAccount("user-admin", "Admin", "admin@example.com", null)));
|
||||
given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1"))
|
||||
.willReturn(adminMemberResponse);
|
||||
given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1")))
|
||||
.willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20));
|
||||
given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1")))
|
||||
.willReturn(adminMemberResponse);
|
||||
given(namespacePortalCommandAppService.removeMember("team-flow", "user-admin", "owner-1"))
|
||||
.willReturn(new com.iflytek.skillhub.dto.MessageResponse("Member removed successfully"));
|
||||
|
||||
mockMvc.perform(post("/api/web/namespaces")
|
||||
.with(csrf())
|
||||
|
|
@ -127,14 +127,18 @@ class NamespaceWorkflowContractTest {
|
|||
.content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-admin"));
|
||||
.andExpect(jsonPath("$.data.userId").value("user-admin"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("Admin"))
|
||||
.andExpect(jsonPath("$.data.email").value("admin@example.com"));
|
||||
|
||||
mockMvc.perform(get("/api/web/namespaces/team-flow/members")
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"));
|
||||
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"))
|
||||
.andExpect(jsonPath("$.data.items[0].displayName").value("Admin"))
|
||||
.andExpect(jsonPath("$.data.items[0].email").value("admin@example.com"));
|
||||
|
||||
mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role")
|
||||
.with(csrf())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.local.PasswordResetService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
|
|
@ -52,6 +53,9 @@ class UserManagementControllerTest {
|
|||
@MockBean
|
||||
private AdminUserAppService adminUserAppService;
|
||||
|
||||
@MockBean
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@Test
|
||||
void listUsers_unauthenticated_returns401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/admin/users"))
|
||||
|
|
@ -243,4 +247,22 @@ class UserManagementControllerTest {
|
|||
|
||||
verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void triggerPasswordReset_withUserAdminRole_returns200() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/users/user-123/password-reset")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(passwordResetService).adminTriggerPasswordReset("user-123", "user-42");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ spring:
|
|||
banner-mode: "off"
|
||||
log-startup-info: false
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
generate-unique-name: true
|
||||
url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ public class LocalAuthService {
|
|||
|
||||
private void validateEmail(String email) {
|
||||
if (email == null) {
|
||||
return;
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank");
|
||||
}
|
||||
if (!EMAIL_PATTERN.matcher(email).matches()) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.iflytek.skillhub.auth.local;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.auth.password-reset")
|
||||
public class PasswordResetProperties {
|
||||
|
||||
private Duration codeExpiry = Duration.ofMinutes(10);
|
||||
private String emailFromAddress = "noreply@skillhub.local";
|
||||
private String emailFromName = "SkillHub";
|
||||
|
||||
public Duration getCodeExpiry() {
|
||||
return codeExpiry;
|
||||
}
|
||||
|
||||
public void setCodeExpiry(Duration codeExpiry) {
|
||||
this.codeExpiry = codeExpiry;
|
||||
}
|
||||
|
||||
public String getEmailFromAddress() {
|
||||
return emailFromAddress;
|
||||
}
|
||||
|
||||
public void setEmailFromAddress(String emailFromAddress) {
|
||||
this.emailFromAddress = emailFromAddress;
|
||||
}
|
||||
|
||||
public String getEmailFromName() {
|
||||
return emailFromName;
|
||||
}
|
||||
|
||||
public void setEmailFromName(String emailFromName) {
|
||||
this.emailFromName = emailFromName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package com.iflytek.skillhub.auth.local;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Local-account password reset flow backed by one-time email verification
|
||||
* codes.
|
||||
*/
|
||||
@Service
|
||||
public class PasswordResetService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class);
|
||||
private static final int VERIFICATION_CODE_DIGITS = 6;
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final PasswordResetRequestRepository resetRequestRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final LocalCredentialRepository credentialRepository;
|
||||
private final PasswordPolicyValidator passwordPolicyValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JavaMailSender mailSender;
|
||||
private final PasswordResetProperties properties;
|
||||
|
||||
public PasswordResetService(PasswordResetRequestRepository resetRequestRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
LocalCredentialRepository credentialRepository,
|
||||
PasswordPolicyValidator passwordPolicyValidator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JavaMailSender mailSender,
|
||||
PasswordResetProperties properties) {
|
||||
this.resetRequestRepository = resetRequestRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.credentialRepository = credentialRepository;
|
||||
this.passwordPolicyValidator = passwordPolicyValidator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.mailSender = mailSender;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymous/self-service reset request. Always silent on ineligible users to
|
||||
* avoid account enumeration.
|
||||
*/
|
||||
@Transactional
|
||||
public void requestPasswordReset(String email) {
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
validateEmail(normalizedEmail);
|
||||
Optional<UserAccount> userOpt = findEligibleUserByEmail(normalizedEmail);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.debug("Password reset requested for ineligible email");
|
||||
return;
|
||||
}
|
||||
|
||||
UserAccount user = userOpt.get();
|
||||
String code = generateVerificationCode();
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(properties.getCodeExpiry());
|
||||
|
||||
invalidatePendingRequests(user.getId(), now);
|
||||
resetRequestRepository.save(new PasswordResetRequest(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
passwordEncoder.encode(code),
|
||||
expiresAt,
|
||||
false,
|
||||
null
|
||||
));
|
||||
|
||||
sendVerificationCodeEmail(user.getEmail(), code, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-triggered reset request for a specific user.
|
||||
*/
|
||||
@Transactional
|
||||
public void adminTriggerPasswordReset(String userId, String adminUserId) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.admin.user.notFound", userId));
|
||||
|
||||
if (!isEligibleForReset(user)) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.not.eligible");
|
||||
}
|
||||
|
||||
String code = generateVerificationCode();
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(properties.getCodeExpiry());
|
||||
|
||||
invalidatePendingRequests(userId, now);
|
||||
resetRequestRepository.save(new PasswordResetRequest(
|
||||
userId,
|
||||
user.getEmail(),
|
||||
passwordEncoder.encode(code),
|
||||
expiresAt,
|
||||
true,
|
||||
adminUserId
|
||||
));
|
||||
|
||||
sendVerificationCodeEmail(user.getEmail(), code, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a code and updates the local credential password.
|
||||
*/
|
||||
@Transactional
|
||||
public void confirmPasswordReset(String email, String code, String newPassword) {
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
validateEmail(normalizedEmail);
|
||||
UserAccount user = findUserByEmail(normalizedEmail)
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code"));
|
||||
|
||||
List<PasswordResetRequest> pendingRequests = resetRequestRepository
|
||||
.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(user.getId(), Instant.now());
|
||||
|
||||
PasswordResetRequest matchedRequest = pendingRequests.stream()
|
||||
.filter(request -> passwordEncoder.matches(code, request.getCodeHash()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code"));
|
||||
|
||||
var passwordErrors = passwordPolicyValidator.validate(newPassword);
|
||||
if (!passwordErrors.isEmpty()) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
|
||||
}
|
||||
|
||||
LocalCredential credential = credentialRepository.findByUserId(user.getId())
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.no.credential"));
|
||||
|
||||
credential.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
credential.setFailedAttempts(0);
|
||||
credential.setLockedUntil(null);
|
||||
credentialRepository.save(credential);
|
||||
|
||||
Instant now = Instant.now();
|
||||
matchedRequest.markConsumed(now);
|
||||
resetRequestRepository.save(matchedRequest);
|
||||
invalidatePendingRequests(user.getId(), now);
|
||||
}
|
||||
|
||||
private void invalidatePendingRequests(String userId, Instant now) {
|
||||
List<PasswordResetRequest> pending = resetRequestRepository
|
||||
.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, now);
|
||||
for (PasswordResetRequest request : pending) {
|
||||
request.markConsumed(now);
|
||||
resetRequestRepository.save(request);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<UserAccount> findEligibleUserByEmail(String normalizedEmail) {
|
||||
return findUserByEmail(normalizedEmail)
|
||||
.filter(this::isEligibleForReset);
|
||||
}
|
||||
|
||||
private Optional<UserAccount> findUserByEmail(String normalizedEmail) {
|
||||
if (!StringUtils.hasText(normalizedEmail)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return userAccountRepository.findByEmailIgnoreCase(normalizedEmail);
|
||||
}
|
||||
|
||||
private boolean isEligibleForReset(UserAccount user) {
|
||||
if (user.getStatus() != UserStatus.ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
if (!StringUtils.hasText(user.getEmail())) {
|
||||
return false;
|
||||
}
|
||||
return credentialRepository.findByUserId(user.getId()).isPresent();
|
||||
}
|
||||
|
||||
private String normalizeEmail(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void validateEmail(String email) {
|
||||
if (email == null) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.notBlank");
|
||||
}
|
||||
if (!EMAIL_PATTERN.matcher(email).matches()) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private String generateVerificationCode() {
|
||||
int bound = (int) Math.pow(10, VERIFICATION_CODE_DIGITS);
|
||||
int code = SECURE_RANDOM.nextInt(bound);
|
||||
return String.format("%0" + VERIFICATION_CODE_DIGITS + "d", code);
|
||||
}
|
||||
|
||||
private void sendVerificationCodeEmail(String email, String code, boolean failOnError) {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(resolveFromAddress());
|
||||
message.setTo(email);
|
||||
message.setSubject("SkillHub password reset verification code");
|
||||
message.setText(buildVerificationCodeBody(code));
|
||||
try {
|
||||
mailSender.send(message);
|
||||
log.info("Password reset verification code sent to {}", email);
|
||||
} catch (Exception ex) {
|
||||
if (failOnError) {
|
||||
log.error("Failed to send password reset verification code to {}", email, ex);
|
||||
throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.password.reset.email.failed");
|
||||
}
|
||||
log.warn("Failed to send password reset verification code to {}", email, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveFromAddress() {
|
||||
String fromAddress = properties.getEmailFromAddress();
|
||||
if (!StringUtils.hasText(properties.getEmailFromName())) {
|
||||
return fromAddress;
|
||||
}
|
||||
return properties.getEmailFromName() + " <" + fromAddress + ">";
|
||||
}
|
||||
|
||||
private String buildVerificationCodeBody(String code) {
|
||||
long expiryMinutes = Math.max(1L, properties.getCodeExpiry().toMinutes());
|
||||
return "Your SkillHub password reset verification code is: " + code
|
||||
+ "\n\nThis code expires in " + expiryMinutes + " minutes."
|
||||
+ "\n\nIf you did not request a password reset, please ignore this email.";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Username-and-password authentication support, including registration,
|
||||
* password changes, and local credential validation.
|
||||
* password changes, password resets, and local credential validation.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.local;
|
||||
|
|
|
|||
|
|
@ -238,4 +238,13 @@ class LocalAuthServiceTest {
|
|||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("validation.auth.local.email.invalid");
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_rejectsBlankEmail() {
|
||||
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.register("Alice", "Abcd123!", " "))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("validation.auth.local.email.notBlank");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package com.iflytek.skillhub.auth.local;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PasswordResetServiceTest {
|
||||
|
||||
@Mock
|
||||
private PasswordResetRequestRepository resetRequestRepository;
|
||||
|
||||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Mock
|
||||
private LocalCredentialRepository credentialRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
private PasswordResetService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PasswordResetProperties properties = new PasswordResetProperties();
|
||||
properties.setCodeExpiry(Duration.ofMinutes(10));
|
||||
properties.setEmailFromAddress("noreply@skillhub.local");
|
||||
properties.setEmailFromName("SkillHub");
|
||||
service = new PasswordResetService(
|
||||
resetRequestRepository,
|
||||
userAccountRepository,
|
||||
credentialRepository,
|
||||
new PasswordPolicyValidator(),
|
||||
passwordEncoder,
|
||||
mailSender,
|
||||
properties
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() {
|
||||
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
|
||||
given(credentialRepository.findByUserId("usr_1")).willReturn(
|
||||
Optional.of(new LocalCredential("usr_1", "alice", "encoded"))
|
||||
);
|
||||
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
anyString(), any(Instant.class))
|
||||
).willReturn(List.of());
|
||||
given(passwordEncoder.encode(anyString())).willReturn("encoded-value");
|
||||
|
||||
service.requestPasswordReset("alice@example.com");
|
||||
|
||||
verify(resetRequestRepository).save(any(PasswordResetRequest.class));
|
||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_withUnknownEmail_doesNothing() {
|
||||
given(userAccountRepository.findByEmailIgnoreCase("ghost@example.com")).willReturn(Optional.empty());
|
||||
|
||||
service.requestPasswordReset("ghost@example.com");
|
||||
|
||||
verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class));
|
||||
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_withInvalidEmail_throwsBadRequest() {
|
||||
assertThatThrownBy(() -> service.requestPasswordReset("alice"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
verifyNoInteractions(userAccountRepository, resetRequestRepository, mailSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestPasswordReset_emailFailure_doesNotThrowForAnonymousFlow() {
|
||||
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
|
||||
given(credentialRepository.findByUserId("usr_1")).willReturn(
|
||||
Optional.of(new LocalCredential("usr_1", "alice", "encoded"))
|
||||
);
|
||||
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
anyString(), any(Instant.class))
|
||||
).willReturn(List.of());
|
||||
given(passwordEncoder.encode(anyString())).willReturn("encoded-value");
|
||||
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||
|
||||
service.requestPasswordReset("alice@example.com");
|
||||
|
||||
verify(resetRequestRepository).save(any(PasswordResetRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminTriggerPasswordReset_withUnknownUser_throwsNotFound() {
|
||||
given(userAccountRepository.findById("missing")).willReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.adminTriggerPasswordReset("missing", "admin_1"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPasswordReset_withValidCode_updatesCredential() {
|
||||
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password");
|
||||
PasswordResetRequest request = new PasswordResetRequest(
|
||||
"usr_1",
|
||||
"alice@example.com",
|
||||
"encoded-code",
|
||||
Instant.now().plus(Duration.ofMinutes(5)),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
|
||||
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
anyString(), any(Instant.class))
|
||||
).willReturn(List.of(request));
|
||||
given(passwordEncoder.matches("123456", "encoded-code")).willReturn(true);
|
||||
given(credentialRepository.findByUserId("usr_1")).willReturn(Optional.of(credential));
|
||||
given(passwordEncoder.encode("Abcd123!")).willReturn("new-password-hash");
|
||||
|
||||
service.confirmPasswordReset("alice@example.com", "123456", "Abcd123!");
|
||||
|
||||
assertThat(credential.getPasswordHash()).isEqualTo("new-password-hash");
|
||||
assertThat(credential.getFailedAttempts()).isZero();
|
||||
assertThat(credential.getLockedUntil()).isNull();
|
||||
verify(credentialRepository).save(credential);
|
||||
|
||||
ArgumentCaptor<PasswordResetRequest> requestCaptor = ArgumentCaptor.forClass(PasswordResetRequest.class);
|
||||
verify(resetRequestRepository, atLeastOnce()).save(requestCaptor.capture());
|
||||
assertThat(requestCaptor.getAllValues())
|
||||
.anySatisfy(captured -> assertThat(captured.getConsumedAt()).isNotNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPasswordReset_withInvalidCode_throwsBadRequest() {
|
||||
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password");
|
||||
PasswordResetRequest request = new PasswordResetRequest(
|
||||
"usr_1",
|
||||
"alice@example.com",
|
||||
"encoded-code",
|
||||
Instant.now().plus(Duration.ofMinutes(5)),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
|
||||
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
anyString(), any(Instant.class))
|
||||
).willReturn(List.of(request));
|
||||
given(passwordEncoder.matches("654321", "encoded-code")).willReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.confirmPasswordReset("alice@example.com", "654321", "Abcd123!"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPasswordReset_withInvalidEmail_throwsBadRequest() {
|
||||
assertThatThrownBy(() -> service.confirmPasswordReset("alice", "123456", "Abcd123!"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminTriggerPasswordReset_forDisabledUser_throwsBadRequest() {
|
||||
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
user.setStatus(UserStatus.DISABLED);
|
||||
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
|
||||
|
||||
assertThatThrownBy(() -> service.adminTriggerPasswordReset("usr_1", "admin_1"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.iflytek.skillhub.domain.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "password_reset_request")
|
||||
public class PasswordResetRequest {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false, length = 128)
|
||||
private String userId;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(name = "code_hash", nullable = false, length = 255)
|
||||
private String codeHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "consumed_at")
|
||||
private Instant consumedAt;
|
||||
|
||||
@Column(name = "requested_by_admin", nullable = false)
|
||||
private boolean requestedByAdmin;
|
||||
|
||||
@Column(name = "requested_by_user_id", length = 128)
|
||||
private String requestedByUserId;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected PasswordResetRequest() {
|
||||
}
|
||||
|
||||
public PasswordResetRequest(String userId,
|
||||
String email,
|
||||
String codeHash,
|
||||
Instant expiresAt,
|
||||
boolean requestedByAdmin,
|
||||
String requestedByUserId) {
|
||||
this.userId = userId;
|
||||
this.email = email;
|
||||
this.codeHash = codeHash;
|
||||
this.expiresAt = expiresAt;
|
||||
this.requestedByAdmin = requestedByAdmin;
|
||||
this.requestedByUserId = requestedByUserId;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now(Clock.systemUTC());
|
||||
}
|
||||
}
|
||||
|
||||
public void markConsumed(Instant timestamp) {
|
||||
this.consumedAt = timestamp;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getCodeHash() {
|
||||
return codeHash;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public Instant getConsumedAt() {
|
||||
return consumedAt;
|
||||
}
|
||||
|
||||
public boolean isRequestedByAdmin() {
|
||||
return requestedByAdmin;
|
||||
}
|
||||
|
||||
public String getRequestedByUserId() {
|
||||
return requestedByUserId;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.domain.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Domain repository contract for local-account password reset verification
|
||||
* codes.
|
||||
*/
|
||||
public interface PasswordResetRequestRepository {
|
||||
PasswordResetRequest save(PasswordResetRequest request);
|
||||
|
||||
List<PasswordResetRequest> findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
String userId,
|
||||
Instant now
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Password-reset domain entities and repository contracts.
|
||||
*/
|
||||
package com.iflytek.skillhub.domain.auth;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.infra.jpa;
|
||||
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
|
||||
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* JPA-backed repository for password-reset verification-code requests.
|
||||
*/
|
||||
public interface PasswordResetRequestJpaRepository
|
||||
extends JpaRepository<PasswordResetRequest, Long>, PasswordResetRequestRepository {
|
||||
|
||||
List<PasswordResetRequest> findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
|
||||
String userId,
|
||||
Instant now
|
||||
);
|
||||
}
|
||||
|
|
@ -5,3 +5,12 @@ export async function setEnglishLocale(page: Page) {
|
|||
window.localStorage.setItem('i18nextLng', 'en')
|
||||
})
|
||||
}
|
||||
|
||||
export async function setUniqueClientIp(page: Page, seed: string) {
|
||||
const suffix = Date.now() + Math.floor(Math.random() * 1000)
|
||||
const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250
|
||||
const fourthOctet = suffix % 250
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
45
web/e2e/password-reset.spec.ts
Normal file
45
web/e2e/password-reset.spec.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
|
||||
|
||||
test.describe('Password Reset (Real API)', () => {
|
||||
function uniqueResetEmail(seed: string) {
|
||||
return `nonexistent_${seed}_${Date.now()}@example.com`
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('sends verification code from reset-password page', async ({ page }) => {
|
||||
const email = uniqueResetEmail('request')
|
||||
await setUniqueClientIp(page, 'password-reset-request')
|
||||
|
||||
await page.goto('/reset-password')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
|
||||
await page.getByLabel('Email').fill(email)
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
|
||||
await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows backend validation error for an invalid reset code', async ({ page }) => {
|
||||
const email = uniqueResetEmail('invalid-code')
|
||||
await setUniqueClientIp(page, 'password-reset-invalid-code')
|
||||
|
||||
await page.goto('/reset-password')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
|
||||
await page.getByLabel('Email').fill(email)
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible()
|
||||
await page.getByLabel('Verification Code').fill('123456')
|
||||
await page.getByLabel('New Password').fill('Passw0rd!123')
|
||||
await page.getByLabel('Confirm Password').fill('Passw0rd!123')
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText(/The verification code is invalid or has expired\.|验证码无效或已过期。/)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
46
web/e2e/register-email-required.spec.ts
Normal file
46
web/e2e/register-email-required.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
function buildUniqueUser() {
|
||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||
return {
|
||||
username: `e2e_reg_${suffix}`,
|
||||
email: `e2e_reg_${suffix}@example.test`,
|
||||
password: 'Passw0rd!123',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Register Email Required (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('registers successfully when email is provided', async ({ page }) => {
|
||||
const user = buildUniqueUser()
|
||||
await page.goto('/register')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible()
|
||||
await page.getByLabel('Username').fill(user.username)
|
||||
await page.getByLabel('Email').fill(user.email)
|
||||
await page.getByLabel('Password').fill(user.password)
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
})
|
||||
|
||||
test('shows required validation when email is missing', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
|
||||
await page.getByLabel('Username').fill(`e2e_no_email_${Date.now().toString(36)}`)
|
||||
await page.getByLabel('Password').fill('Passw0rd!123')
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
|
||||
const isEmailMissing = await page.getByLabel('Email').evaluate((element) => {
|
||||
const input = element as HTMLInputElement
|
||||
return input.validity.valueMissing
|
||||
})
|
||||
|
||||
expect(isEmailMissing).toBeTruthy()
|
||||
await expect(page).toHaveURL(/\/register/)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
|
||||
import { createFreshSession } from './helpers/session'
|
||||
|
||||
// TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框
|
||||
|
|
@ -10,14 +10,16 @@ const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i
|
|||
const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/
|
||||
|
||||
test.describe('Register - Username Validation (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
|
||||
await page.goto('/register')
|
||||
})
|
||||
|
||||
// TC_UN_008 P0
|
||||
test('TC_UN_008: shows required error when username is empty', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByLabel(/username/i).click()
|
||||
await page.getByLabel(/email/i).click()
|
||||
await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
@ -51,18 +53,19 @@ test.describe('Register - Username Validation (Real API)', () => {
|
|||
})
|
||||
|
||||
test.describe('Register - Email Validation (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
|
||||
await page.goto('/register')
|
||||
})
|
||||
|
||||
// TC_EM_007 P0 - email is optional
|
||||
test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => {
|
||||
// TC_EM_007 P0 - email is required
|
||||
test('TC_EM_007: shows required error when email is empty', async ({ page }) => {
|
||||
const emailField = page.getByLabel(/email/i)
|
||||
if (await emailField.isVisible()) {
|
||||
await emailField.clear()
|
||||
await emailField.blur()
|
||||
await expect(page.getByText(/email.*required/i)).not.toBeVisible()
|
||||
await expect(page.getByText(/email.*required|required.*email/i)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -88,14 +91,16 @@ test.describe('Register - Email Validation (Real API)', () => {
|
|||
})
|
||||
|
||||
test.describe('Register - Password Validation (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
|
||||
await page.goto('/register')
|
||||
})
|
||||
|
||||
// TC_PW_013 P0 - empty password
|
||||
test('TC_PW_013: shows required error when password is empty', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByLabel(/^password/i).click()
|
||||
await page.getByLabel(/username/i).click()
|
||||
await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
@ -123,8 +128,9 @@ test.describe('Register - Password Validation (Real API)', () => {
|
|||
})
|
||||
|
||||
test.describe('Register Flow (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
|
||||
})
|
||||
|
||||
// TC_REG_001 P0 - successful registration with all fields
|
||||
|
|
@ -138,7 +144,7 @@ test.describe('Register Flow (Real API)', () => {
|
|||
await emailField.fill(`test_${suffix}@example.test`)
|
||||
}
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
// Should redirect away from /register on success
|
||||
await expect(page).not.toHaveURL('/register')
|
||||
existingRegisteredUsername = username
|
||||
|
|
@ -160,13 +166,14 @@ test.describe('Register Flow (Real API)', () => {
|
|||
await page.goto('/register')
|
||||
await setEnglishLocale(page)
|
||||
await page.getByLabel(/username/i).fill(username)
|
||||
await page.getByLabel(/email/i).fill(`duplicate_${Date.now()}@example.test`)
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
const main = page.getByRole('main')
|
||||
const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first()
|
||||
const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first()
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
|
||||
if (await duplicateUsernameError.isVisible().catch(() => false)) {
|
||||
return
|
||||
|
|
@ -184,27 +191,35 @@ test.describe('Register Flow (Real API)', () => {
|
|||
})
|
||||
|
||||
// TC_REG_002 P0 - registration without email
|
||||
test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => {
|
||||
test('TC_REG_002: shows required validation when email is missing', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
|
||||
await page.getByLabel(/username/i).fill(`noemail_${suffix}`)
|
||||
await page.getByLabel(/username/i).fill(`emailrequired_${suffix}`)
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await expect(page).not.toHaveURL('/register')
|
||||
await page.getByLabel(/email/i).click()
|
||||
await page.getByLabel(/username/i).click()
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
const emailField = page.getByLabel(/email/i)
|
||||
await expect(emailField).toBeFocused()
|
||||
await expect
|
||||
.poll(async () => emailField.evaluate((input) => (input as HTMLInputElement).validity.valueMissing))
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
// TC_REG_005 P0 - required fields empty on submit
|
||||
test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByLabel(/email/i).fill(`required_fields_${Date.now()}@example.test`)
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible()
|
||||
await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Login Flow (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
|
||||
})
|
||||
|
||||
// TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here)
|
||||
|
|
@ -223,8 +238,9 @@ test.describe('Login Flow (Real API)', () => {
|
|||
|
||||
await page.goto('/register')
|
||||
await page.getByLabel(/username/i).fill(username)
|
||||
await page.getByLabel(/email/i).fill(`login_${suffix}@example.test`)
|
||||
await page.getByLabel(/^password/i).fill('Test123!@')
|
||||
await page.getByRole('button', { name: 'Register' }).click()
|
||||
await page.getByRole('button', { name: 'Register & Login' }).click()
|
||||
await expect(page).not.toHaveURL('/register')
|
||||
|
||||
await page.goto('/login')
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ test.describe('Settings Pages (Real API)', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates to reset-password page from profile settings', async ({ page }) => {
|
||||
await page.goto('/settings/profile')
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click()
|
||||
await expect(page).toHaveURL('/reset-password')
|
||||
await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation when current password is missing', async ({ page }) => {
|
||||
await page.goto('/settings/security')
|
||||
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import createClient from 'openapi-fetch'
|
|||
import type { paths } from './generated/schema'
|
||||
import type {
|
||||
ChangePasswordRequest,
|
||||
PasswordResetConfirmRequest,
|
||||
PasswordResetRequest,
|
||||
ApiToken,
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
|
|
@ -354,6 +356,26 @@ export const authApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async requestPasswordReset(request: PasswordResetRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/auth/local/password-reset/request', {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/auth/local/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const response = await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
|
|
@ -1093,6 +1115,13 @@ export const adminApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async triggerPasswordReset(userId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/users/${userId}/password-reset`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async getAuditLogs(params: {
|
||||
action?: string
|
||||
userId?: string
|
||||
|
|
|
|||
128
web/src/api/generated/schema.d.ts
vendored
128
web/src/api/generated/schema.d.ts
vendored
|
|
@ -1140,6 +1140,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/local/password-reset/request": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["requestPasswordReset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/local/password-reset/confirm": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["confirmPasswordReset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/local/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1220,6 +1252,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users/{userId}/password-reset": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["triggerPasswordReset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users/{userId}/enable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3413,7 +3461,15 @@ export interface components {
|
|||
LocalRegisterRequest: {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
email: string;
|
||||
};
|
||||
PasswordResetRequestDto: {
|
||||
email: string;
|
||||
};
|
||||
PasswordResetConfirmRequest: {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
};
|
||||
LocalLoginRequest: {
|
||||
username: string;
|
||||
|
|
@ -6823,6 +6879,54 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
requestPasswordReset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordResetRequestDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
confirmPasswordReset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordResetConfirmRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -6939,6 +7043,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
triggerPasswordReset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
enableUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export interface LocalLoginRequest {
|
|||
}
|
||||
|
||||
export interface LocalRegisterRequest extends LocalLoginRequest {
|
||||
email?: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
|
|
@ -61,6 +61,16 @@ export interface ChangePasswordRequest {
|
|||
newPassword: string
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmRequest {
|
||||
email: string
|
||||
code: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export type CreateNamespaceRequest = Omit<components['schemas']['NamespaceRequest'], 'slug' | 'displayName'> & {
|
||||
slug: string
|
||||
displayName: string
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'L
|
|||
const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
|
||||
const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
|
||||
const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
|
||||
const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage')
|
||||
const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
|
||||
const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
|
||||
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
|
||||
|
|
@ -184,6 +185,12 @@ const registerRoute = createRoute({
|
|||
component: RegisterPage,
|
||||
})
|
||||
|
||||
const resetPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'reset-password',
|
||||
component: ResetPasswordPage,
|
||||
})
|
||||
|
||||
const privacyRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'privacy',
|
||||
|
|
@ -397,6 +404,7 @@ const routeTree = rootRoute.addChildren([
|
|||
skillsRoute,
|
||||
loginRoute,
|
||||
registerRoute,
|
||||
resetPasswordRoute,
|
||||
privacyRoute,
|
||||
searchRoute,
|
||||
termsRoute,
|
||||
|
|
|
|||
|
|
@ -97,3 +97,13 @@ export function useEnableUser() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTriggerUserPasswordReset() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => adminApi.triggerPasswordReset(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@
|
|||
"hidePassword": "Hide password",
|
||||
"submitting": "Logging in...",
|
||||
"submit": "Login",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Sign up now",
|
||||
"oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.",
|
||||
|
|
@ -232,7 +233,8 @@
|
|||
"email": "Email",
|
||||
"password": "Password",
|
||||
"usernamePlaceholder": "3-64 characters: letters, numbers, or underscores",
|
||||
"emailPlaceholder": "Optional, for account identification",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"emailRequired": "Email is required",
|
||||
"passwordPlaceholder": "At least 8 characters with 3 character types",
|
||||
"submitting": "Registering...",
|
||||
"submit": "Register & Login",
|
||||
|
|
@ -248,6 +250,31 @@
|
|||
"emailInvalid": "Invalid email format",
|
||||
"emailExists": "Email already exists"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset Password",
|
||||
"subtitle": "Enter your email, verification code, and new password.",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "Enter email",
|
||||
"emailRequired": "Please enter your email",
|
||||
"emailInvalid": "Please enter a valid email address",
|
||||
"code": "Verification Code",
|
||||
"codePlaceholder": "Enter 6-digit verification code",
|
||||
"sendCode": "Send Verification Code",
|
||||
"sendingCode": "Sending...",
|
||||
"codeSentMessage": "If the account is eligible, a verification code has been sent.",
|
||||
"codeRequired": "Please enter the verification code",
|
||||
"newPassword": "New Password",
|
||||
"newPasswordPlaceholder": "Enter new password",
|
||||
"newPasswordRequired": "Please enter a new password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"confirmPasswordPlaceholder": "Re-enter new password",
|
||||
"passwordMismatch": "The two passwords do not match",
|
||||
"submit": "Reset Password",
|
||||
"submitting": "Resetting...",
|
||||
"successMessage": "Password reset successful. Please sign in with your new password.",
|
||||
"genericError": "Failed to reset password",
|
||||
"backToLogin": "Back to login"
|
||||
},
|
||||
"device": {
|
||||
"title": "Device Authorization",
|
||||
"subtitle": "Enter the 8-digit user code shown on your device",
|
||||
|
|
@ -529,6 +556,7 @@
|
|||
"approveUser": "Approve",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"resetPassword": "Reset Password",
|
||||
"totalRecords": "Total {{total}} records, page {{page}}",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
|
|
@ -543,7 +571,8 @@
|
|||
"roleSuperAdmin": "Super Admin",
|
||||
"confirmAction": "Confirm Action",
|
||||
"confirmDisable": "Are you sure you want to disable user {{username}}?",
|
||||
"confirmEnable": "Are you sure you want to enable user {{username}}?"
|
||||
"confirmEnable": "Are you sure you want to enable user {{username}}?",
|
||||
"confirmResetPassword": "Send password reset verification code to user {{username}}?"
|
||||
},
|
||||
"adminLabels": {
|
||||
"title": "Label Management",
|
||||
|
|
@ -650,6 +679,7 @@
|
|||
"subtitle": "Manage your display name and personal information.",
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"resetPassword": "Reset Password",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@
|
|||
"hidePassword": "隐藏密码",
|
||||
"submitting": "登录中...",
|
||||
"submit": "登录",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "立即注册",
|
||||
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
|
||||
|
|
@ -232,7 +233,8 @@
|
|||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"usernamePlaceholder": "3-64 位字母、数字或下划线",
|
||||
"emailPlaceholder": "可选,用于后续账号识别",
|
||||
"emailPlaceholder": "请输入邮箱",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordPlaceholder": "至少 8 位,包含 3 种字符类型",
|
||||
"submitting": "注册中...",
|
||||
"submit": "注册并登录",
|
||||
|
|
@ -248,6 +250,31 @@
|
|||
"emailInvalid": "邮箱格式不正确",
|
||||
"emailExists": "邮箱已存在"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "重置密码",
|
||||
"subtitle": "输入邮箱、验证码和新密码以完成重置。",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "请输入邮箱",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"emailInvalid": "请输入正确的邮箱格式",
|
||||
"code": "验证码",
|
||||
"codePlaceholder": "请输入 6 位验证码",
|
||||
"sendCode": "发送验证码",
|
||||
"sendingCode": "发送中...",
|
||||
"codeSentMessage": "如果账号符合条件,验证码已发送。",
|
||||
"codeRequired": "请输入验证码",
|
||||
"newPassword": "新密码",
|
||||
"newPasswordPlaceholder": "请输入新密码",
|
||||
"newPasswordRequired": "请输入新密码",
|
||||
"confirmPassword": "确认新密码",
|
||||
"confirmPasswordPlaceholder": "请再次输入新密码",
|
||||
"passwordMismatch": "两次输入的密码不一致",
|
||||
"submit": "重置密码",
|
||||
"submitting": "重置中...",
|
||||
"successMessage": "密码重置成功,请使用新密码登录。",
|
||||
"genericError": "重置密码失败",
|
||||
"backToLogin": "返回登录"
|
||||
},
|
||||
"device": {
|
||||
"title": "设备授权",
|
||||
"subtitle": "请输入设备上显示的 8 位用户码",
|
||||
|
|
@ -529,6 +556,7 @@
|
|||
"approveUser": "审批通过",
|
||||
"disable": "禁用",
|
||||
"enable": "启用",
|
||||
"resetPassword": "重置密码",
|
||||
"totalRecords": "共 {{total}} 条记录,第 {{page}} 页",
|
||||
"prevPage": "上一页",
|
||||
"nextPage": "下一页",
|
||||
|
|
@ -543,7 +571,8 @@
|
|||
"roleSuperAdmin": "超级管理员",
|
||||
"confirmAction": "确认操作",
|
||||
"confirmDisable": "确定要禁用用户 {{username}} 吗?",
|
||||
"confirmEnable": "确定要启用用户 {{username}} 吗?"
|
||||
"confirmEnable": "确定要启用用户 {{username}} 吗?",
|
||||
"confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?"
|
||||
},
|
||||
"adminLabels": {
|
||||
"title": "标签管理",
|
||||
|
|
@ -650,6 +679,7 @@
|
|||
"subtitle": "管理你的昵称和个人信息。",
|
||||
"displayName": "昵称",
|
||||
"email": "邮箱",
|
||||
"resetPassword": "重置密码",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ vi.mock('@/features/admin/use-admin-users', () => ({
|
|||
useApproveUser: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useTriggerUserPasswordReset: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users'
|
||||
import {
|
||||
useAdminUsers,
|
||||
useApproveUser,
|
||||
useDisableUser,
|
||||
useEnableUser,
|
||||
useTriggerUserPasswordReset,
|
||||
useUpdateUserRole,
|
||||
} from '@/features/admin/use-admin-users'
|
||||
import type { AdminUser } from '@/features/admin/use-admin-users'
|
||||
|
||||
/**
|
||||
|
|
@ -54,7 +61,7 @@ export function AdminUsersPage() {
|
|||
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
|
||||
const [newRole, setNewRole] = useState('')
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
||||
const [actionType, setActionType] = useState<'ban' | 'unban'>('ban')
|
||||
const [actionType, setActionType] = useState<'ban' | 'unban' | 'reset'>('ban')
|
||||
|
||||
const { data, isLoading } = useAdminUsers({
|
||||
search,
|
||||
|
|
@ -67,6 +74,7 @@ export function AdminUsersPage() {
|
|||
const approveUserMutation = useApproveUser()
|
||||
const disableUserMutation = useDisableUser()
|
||||
const enableUserMutation = useEnableUser()
|
||||
const triggerPasswordResetMutation = useTriggerUserPasswordReset()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return formatLocalDateTime(dateString, i18n.language)
|
||||
|
|
@ -105,6 +113,12 @@ export function AdminUsersPage() {
|
|||
setConfirmDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleTriggerPasswordReset = (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setActionType('reset')
|
||||
setConfirmDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmRoleChange = async () => {
|
||||
if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return
|
||||
try {
|
||||
|
|
@ -116,18 +130,20 @@ export function AdminUsersPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
const confirmUserAction = async () => {
|
||||
if (!selectedUser) return
|
||||
try {
|
||||
if (actionType === 'ban') {
|
||||
await disableUserMutation.mutateAsync(selectedUser.userId)
|
||||
} else {
|
||||
} else if (actionType === 'unban') {
|
||||
await enableUserMutation.mutateAsync(selectedUser.userId)
|
||||
} else {
|
||||
await triggerPasswordResetMutation.mutateAsync(selectedUser.userId)
|
||||
}
|
||||
setConfirmDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error)
|
||||
console.error('Failed to apply user action:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +275,13 @@ export function AdminUsersPage() {
|
|||
{t('adminUsers.enable')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTriggerPasswordReset(user)}
|
||||
>
|
||||
{t('adminUsers.resetPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -342,14 +365,21 @@ export function AdminUsersPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>{t('adminUsers.confirmAction')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{actionType === 'ban' ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) : t('adminUsers.confirmEnable', { username: selectedUser?.username })}
|
||||
{actionType === 'ban'
|
||||
? t('adminUsers.confirmDisable', { username: selectedUser?.username })
|
||||
: actionType === 'unban'
|
||||
? t('adminUsers.confirmEnable', { username: selectedUser?.username })
|
||||
: t('adminUsers.confirmResetPassword', { username: selectedUser?.username })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={confirmStatusChange} disabled={disableUserMutation.isPending || enableUserMutation.isPending}>
|
||||
<Button
|
||||
onClick={confirmUserAction}
|
||||
disabled={disableUserMutation.isPending || enableUserMutation.isPending || triggerPasswordResetMutation.isPending}
|
||||
>
|
||||
{t('dialog.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -160,6 +160,11 @@ export function LoginPage() {
|
|||
<Button className="w-full" disabled={loginMutation.isPending} type="submit">
|
||||
{loginMutation.isPending ? t('login.submitting') : t('login.submit')}
|
||||
</Button>
|
||||
<p className="text-center text-sm">
|
||||
<Link to="/reset-password" className="font-medium text-primary hover:underline">
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('login.noAccount')}
|
||||
{' '}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function RegisterPage() {
|
|||
function validateEmail(value: string) {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
return t('register.emailRequired')
|
||||
}
|
||||
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||
return t('register.emailInvalid')
|
||||
|
|
@ -112,6 +112,8 @@ export function RegisterPage() {
|
|||
return { fieldErrors: { username: t('register.usernameRequired') } }
|
||||
case 'validation.auth.local.password.notBlank':
|
||||
return { fieldErrors: { password: t('register.passwordRequired') } }
|
||||
case 'validation.auth.local.email.notBlank':
|
||||
return { fieldErrors: { email: t('register.emailRequired') } }
|
||||
case 'validation.auth.local.email.invalid':
|
||||
return { fieldErrors: { email: t('register.emailInvalid') } }
|
||||
case 'error.auth.local.username.invalid':
|
||||
|
|
@ -218,6 +220,7 @@ export function RegisterPage() {
|
|||
}
|
||||
}}
|
||||
placeholder={t('register.emailPlaceholder')}
|
||||
required
|
||||
aria-invalid={fieldErrors.email ? 'true' : 'false'}
|
||||
onBlur={() => {
|
||||
setFieldErrors((current) => ({ ...current, email: validateEmail(email) }))
|
||||
|
|
|
|||
54
web/src/pages/reset-password.test.tsx
Normal file
54
web/src/pages/reset-password.test.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
requestPasswordReset: vi.fn(),
|
||||
confirmPasswordReset: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { ResetPasswordPage } from './reset-password'
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ResetPasswordPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders reset-password title and submit action', () => {
|
||||
const html = renderToStaticMarkup(<ResetPasswordPage />)
|
||||
expect(html).toContain('resetPassword.title')
|
||||
expect(html).toContain('resetPassword.sendCode')
|
||||
expect(html).toContain('resetPassword.submit')
|
||||
})
|
||||
})
|
||||
187
web/src/pages/reset-password.tsx
Normal file
187
web/src/pages/reset-password.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { authApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
|
||||
/**
|
||||
* Public page for verifying a reset code and setting a new password.
|
||||
*/
|
||||
export function ResetPasswordPage() {
|
||||
const { t } = useTranslation()
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [codeSentMessage, setCodeSentMessage] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const emailPattern = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
|
||||
|
||||
async function handleSendCode() {
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
if (!normalizedEmail) {
|
||||
setErrorMessage(t('resetPassword.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (!emailPattern.test(normalizedEmail)) {
|
||||
setErrorMessage(t('resetPassword.emailInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSendingCode(true)
|
||||
setErrorMessage(null)
|
||||
setCodeSentMessage(null)
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: normalizedEmail })
|
||||
setCodeSentMessage(t('resetPassword.codeSentMessage'))
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError'))
|
||||
} finally {
|
||||
setIsSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
if (!normalizedEmail) {
|
||||
setErrorMessage(t('resetPassword.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (!emailPattern.test(normalizedEmail)) {
|
||||
setErrorMessage(t('resetPassword.emailInvalid'))
|
||||
return
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setErrorMessage(t('resetPassword.codeRequired'))
|
||||
return
|
||||
}
|
||||
if (!newPassword) {
|
||||
setErrorMessage(t('resetPassword.newPasswordRequired'))
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setErrorMessage(t('resetPassword.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
await authApi.confirmPasswordReset({
|
||||
email: normalizedEmail,
|
||||
code: code.trim(),
|
||||
newPassword,
|
||||
})
|
||||
setIsSuccess(true)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSuccess ? (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
{t('resetPassword.successMessage')}
|
||||
</p>
|
||||
<Link to="/login" search={{ returnTo: '' }} className="block text-center font-medium text-primary hover:underline">
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="reset-password-email">
|
||||
{t('resetPassword.email')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="reset-password-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder={t('resetPassword.emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
className="sm:w-auto"
|
||||
disabled={isSendingCode || isSubmitting}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{isSendingCode ? t('resetPassword.sendingCode') : t('resetPassword.sendCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="reset-password-code">
|
||||
{t('resetPassword.code')}
|
||||
</label>
|
||||
<Input
|
||||
id="reset-password-code"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder={t('resetPassword.codePlaceholder')}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="reset-password-new-password">
|
||||
{t('resetPassword.newPassword')}
|
||||
</label>
|
||||
<Input
|
||||
id="reset-password-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder={t('resetPassword.newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="reset-password-confirm-password">
|
||||
{t('resetPassword.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
id="reset-password-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder={t('resetPassword.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-600">{errorMessage}</p>
|
||||
) : null}
|
||||
{codeSentMessage ? (
|
||||
<p className="text-sm text-emerald-700">{codeSentMessage}</p>
|
||||
) : null}
|
||||
<Button className="w-full" disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? t('resetPassword.submitting') : t('resetPassword.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
|
|
@ -10,6 +11,10 @@ vi.mock('react-i18next', async () => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: null }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
|
|
@ -53,10 +58,16 @@ vi.mock('@/shared/ui/input', () => ({
|
|||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { ProfileSettingsPage } from './profile'
|
||||
|
||||
describe('ProfileSettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ProfileSettingsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders reset-password entry action', () => {
|
||||
const html = renderToStaticMarkup(React.createElement(ProfileSettingsPage))
|
||||
expect(html).toContain('profile.resetPassword')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, profileApi } from '@/api/client'
|
||||
|
|
@ -38,6 +39,7 @@ function getFieldValue(
|
|||
export function ProfileSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
|
@ -180,10 +182,17 @@ export function ProfileSettingsPage() {
|
|||
<CardTitle>{t('profile.title')}</CardTitle>
|
||||
<CardDescription>{t('profile.subtitle')}</CardDescription>
|
||||
</div>
|
||||
{!isEditing && hasEditableFields ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
|
||||
{t('profile.edit')}
|
||||
</Button>
|
||||
{!isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void navigate({ to: '/reset-password' })}>
|
||||
{t('profile.resetPassword')}
|
||||
</Button>
|
||||
{hasEditableFields ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
|
||||
{t('profile.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import type { FileTreeNode } from '@/features/skill/file-tree-builder'
|
|||
import { InstallCommand } from '@/features/skill/install-command'
|
||||
import { ShareButton } from '@/features/skill/share-button'
|
||||
import { SkillLabelPanel } from '@/features/skill/skill-label-panel'
|
||||
import { VersionStatusBadge, getVersionRowStyle } from '@/features/skill/version-status-badge'
|
||||
import {
|
||||
getOverviewCollapseMaxHeight,
|
||||
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
|
||||
|
|
@ -385,6 +384,19 @@ export function SkillDetailPage() {
|
|||
return status ?? ''
|
||||
}
|
||||
|
||||
const resolveVersionStatusLabel = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
DRAFT: t('skillDetail.versionStatusDraft'),
|
||||
SCANNING: t('skillDetail.versionStatusScanning'),
|
||||
SCAN_FAILED: t('skillDetail.versionStatusScanFailed'),
|
||||
UPLOADED: t('skillDetail.versionStatusUploaded'),
|
||||
PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'),
|
||||
PUBLISHED: t('skillDetail.versionStatusPublished'),
|
||||
REJECTED: t('skillDetail.versionStatusRejected'),
|
||||
YANKED: t('skillDetail.versionStatusYanked'),
|
||||
}
|
||||
return status ? (map[status] ?? status) : ''
|
||||
}
|
||||
|
||||
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED'
|
||||
const isLastVersion = versions?.length === 1
|
||||
|
|
@ -883,19 +895,21 @@ export function SkillDetailPage() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="versions" className="mt-6">
|
||||
<Card className="p-6">
|
||||
{versions && versions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0 divide-y divide-border/40">
|
||||
{versions.map((version) => (
|
||||
<div key={version.id} className={cn(
|
||||
'rounded-lg border border-border/60 bg-card p-4',
|
||||
getVersionRowStyle(version.status),
|
||||
)}>
|
||||
<div key={version.id} className="py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<span className="font-semibold font-heading text-foreground flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
v{version.version}
|
||||
</span>
|
||||
<VersionStatusBadge status={version.status} />
|
||||
{version.status && (
|
||||
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{resolveVersionStatusLabel(version.status)}
|
||||
</span>
|
||||
)}
|
||||
{headlineVersion?.version === version.version && (
|
||||
<span className="rounded-full bg-primary px-2.5 py-0.5 text-xs text-primary-foreground">
|
||||
{t(hasPublishedPendingReview && publishedVersion?.version === version.version
|
||||
|
|
@ -947,6 +961,7 @@ export function SkillDetailPage() {
|
|||
{skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmPublishTarget(version.version)}
|
||||
>
|
||||
{t('skillDetail.confirmPublish')}
|
||||
|
|
@ -977,6 +992,7 @@ export function SkillDetailPage() {
|
|||
) : (
|
||||
<Card className="p-8 text-muted-foreground text-center">{t('skillDetail.noVersions')}</Card>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue