feat(auth): 邮箱验证码重置密码与 SMTP 配置支持 (#273)

* feat(auth): add email-based password reset with SMTP config docs

* test(e2e): stabilize password reset flow

* test(e2e): isolate password reset rate limits

* test(ci): stabilize backend and register e2e

* docs(auth): sanitize smtp setup examples
This commit is contained in:
dongmucat 2026-04-13 20:27:00 +08:00 committed by GitHub
parent 1184e00a00
commit 38ebb13133
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2005 additions and 74 deletions

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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 裸金属上线清单

View 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 授权码。

View file

@ -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)) {

View file

@ -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);
}
}

View file

@ -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
) {}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -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:

View file

@ -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';

View file

@ -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

View file

@ -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=新密码不能为空

View file

@ -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!");
}
}

View file

@ -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())

View file

@ -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");
}
}

View file

@ -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:

View file

@ -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>

View file

@ -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");

View file

@ -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;
}
}

View file

@ -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.";
}
}

View file

@ -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;

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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
);
}

View file

@ -0,0 +1,4 @@
/**
* Password-reset domain entities and repository contracts.
*/
package com.iflytek.skillhub.domain.auth;

View file

@ -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
);
}

View file

@ -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}`,
})
}

View 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()
})
})

View 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/)
})
})

View file

@ -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')

View file

@ -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()

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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,

View file

@ -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'] })
},
})
}

View file

@ -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...",

View file

@ -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": "保存中...",

View file

@ -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 }),
}))

View file

@ -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>

View file

@ -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')}
{' '}

View file

@ -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) }))

View 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')
})
})

View 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>
)
}

View file

@ -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')
})
})

View file

@ -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">