Backend: - Add ChangePasswordReq DTO with validation (current + new password) - Add AuthService::change_password() method with credential verification, password rehash, and token revocation - Add POST /api/v1/auth/change-password endpoint with utoipa annotation Frontend: - Add changePassword() API function in auth.ts - Add ChangePassword.tsx page with form validation and confirmation - Add "修改密码" tab in Settings page After password change, all refresh tokens are revoked and the user is redirected to the login page.
437 lines
11 KiB
Rust
437 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
use validator::Validate;
|
|
|
|
// --- Auth DTOs ---
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct LoginReq {
|
|
#[validate(length(min = 1, message = "用户名不能为空"))]
|
|
pub username: String,
|
|
#[validate(length(min = 1, message = "密码不能为空"))]
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct LoginResp {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
pub expires_in: u64,
|
|
pub user: UserResp,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct RefreshReq {
|
|
pub refresh_token: String,
|
|
}
|
|
|
|
/// 修改密码请求
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct ChangePasswordReq {
|
|
#[validate(length(min = 1, message = "当前密码不能为空"))]
|
|
pub current_password: String,
|
|
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
|
pub new_password: String,
|
|
}
|
|
|
|
// --- User DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct UserResp {
|
|
pub id: Uuid,
|
|
pub username: String,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub status: String,
|
|
pub roles: Vec<RoleResp>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateUserReq {
|
|
#[validate(length(min = 1, max = 50))]
|
|
pub username: String,
|
|
#[validate(length(min = 6, max = 128))]
|
|
pub password: String,
|
|
#[validate(email)]
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateUserReq {
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub status: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
// --- Role DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct RoleResp {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub code: String,
|
|
pub description: Option<String>,
|
|
pub is_system: bool,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateRoleReq {
|
|
#[validate(length(min = 1, max = 50))]
|
|
pub name: String,
|
|
#[validate(length(min = 1, max = 50))]
|
|
pub code: String,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateRoleReq {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct AssignRolesReq {
|
|
pub role_ids: Vec<Uuid>,
|
|
}
|
|
|
|
// --- Permission DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct PermissionResp {
|
|
pub id: Uuid,
|
|
pub code: String,
|
|
pub name: String,
|
|
pub resource: String,
|
|
pub action: String,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct AssignPermissionsReq {
|
|
pub permission_ids: Vec<Uuid>,
|
|
}
|
|
|
|
// --- Organization DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct OrganizationResp {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub path: Option<String>,
|
|
pub level: i32,
|
|
pub sort_order: i32,
|
|
pub children: Vec<OrganizationResp>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateOrganizationReq {
|
|
#[validate(length(min = 1))]
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub sort_order: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateOrganizationReq {
|
|
pub name: Option<String>,
|
|
pub code: Option<String>,
|
|
pub sort_order: Option<i32>,
|
|
pub version: i32,
|
|
}
|
|
|
|
// --- Department DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct DepartmentResp {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub manager_id: Option<Uuid>,
|
|
pub path: Option<String>,
|
|
pub sort_order: i32,
|
|
pub children: Vec<DepartmentResp>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateDepartmentReq {
|
|
#[validate(length(min = 1))]
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub manager_id: Option<Uuid>,
|
|
pub sort_order: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateDepartmentReq {
|
|
pub name: Option<String>,
|
|
pub code: Option<String>,
|
|
pub manager_id: Option<Uuid>,
|
|
pub sort_order: Option<i32>,
|
|
pub version: i32,
|
|
}
|
|
|
|
// --- Position DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct PositionResp {
|
|
pub id: Uuid,
|
|
pub dept_id: Uuid,
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub level: i32,
|
|
pub sort_order: i32,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreatePositionReq {
|
|
#[validate(length(min = 1))]
|
|
pub name: String,
|
|
pub code: Option<String>,
|
|
pub level: Option<i32>,
|
|
pub sort_order: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdatePositionReq {
|
|
pub name: Option<String>,
|
|
pub code: Option<String>,
|
|
pub level: Option<i32>,
|
|
pub sort_order: Option<i32>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use validator::Validate;
|
|
|
|
#[test]
|
|
fn login_req_valid() {
|
|
let req = LoginReq {
|
|
username: "admin".to_string(),
|
|
password: "password123".to_string(),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn login_req_empty_username_fails() {
|
|
let req = LoginReq {
|
|
username: "".to_string(),
|
|
password: "password123".to_string(),
|
|
};
|
|
let result = req.validate();
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn change_password_req_valid() {
|
|
let req = ChangePasswordReq {
|
|
current_password: "oldPassword123".to_string(),
|
|
new_password: "newPassword456".to_string(),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn change_password_req_empty_current_fails() {
|
|
let req = ChangePasswordReq {
|
|
current_password: "".to_string(),
|
|
new_password: "newPassword456".to_string(),
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn change_password_req_short_new_fails() {
|
|
let req = ChangePasswordReq {
|
|
current_password: "oldPassword123".to_string(),
|
|
new_password: "12345".to_string(), // min 6
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn change_password_req_long_new_fails() {
|
|
let req = ChangePasswordReq {
|
|
current_password: "oldPassword123".to_string(),
|
|
new_password: "a".repeat(129), // max 128
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn login_req_empty_password_fails() {
|
|
let req = LoginReq {
|
|
username: "admin".to_string(),
|
|
password: "".to_string(),
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_user_req_valid() {
|
|
let req = CreateUserReq {
|
|
username: "alice".to_string(),
|
|
password: "secret123".to_string(),
|
|
email: Some("alice@example.com".to_string()),
|
|
phone: None,
|
|
display_name: Some("Alice".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_user_req_short_password_fails() {
|
|
let req = CreateUserReq {
|
|
username: "bob".to_string(),
|
|
password: "12345".to_string(), // min 6
|
|
email: None,
|
|
phone: None,
|
|
display_name: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_user_req_empty_username_fails() {
|
|
let req = CreateUserReq {
|
|
username: "".to_string(),
|
|
password: "secret123".to_string(),
|
|
email: None,
|
|
phone: None,
|
|
display_name: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_user_req_invalid_email_fails() {
|
|
let req = CreateUserReq {
|
|
username: "charlie".to_string(),
|
|
password: "secret123".to_string(),
|
|
email: Some("not-an-email".to_string()),
|
|
phone: None,
|
|
display_name: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_user_req_long_username_fails() {
|
|
let req = CreateUserReq {
|
|
username: "a".repeat(51), // max 50
|
|
password: "secret123".to_string(),
|
|
email: None,
|
|
phone: None,
|
|
display_name: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_role_req_valid() {
|
|
let req = CreateRoleReq {
|
|
name: "管理员".to_string(),
|
|
code: "admin".to_string(),
|
|
description: Some("系统管理员".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_role_req_empty_name_fails() {
|
|
let req = CreateRoleReq {
|
|
name: "".to_string(),
|
|
code: "admin".to_string(),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_role_req_empty_code_fails() {
|
|
let req = CreateRoleReq {
|
|
name: "管理员".to_string(),
|
|
code: "".to_string(),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_org_req_valid() {
|
|
let req = CreateOrganizationReq {
|
|
name: "总部".to_string(),
|
|
code: Some("HQ".to_string()),
|
|
parent_id: None,
|
|
sort_order: Some(0),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_org_req_empty_name_fails() {
|
|
let req = CreateOrganizationReq {
|
|
name: "".to_string(),
|
|
code: None,
|
|
parent_id: None,
|
|
sort_order: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dept_req_valid() {
|
|
let req = CreateDepartmentReq {
|
|
name: "技术部".to_string(),
|
|
code: Some("TECH".to_string()),
|
|
parent_id: None,
|
|
manager_id: None,
|
|
sort_order: Some(1),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_position_req_valid() {
|
|
let req = CreatePositionReq {
|
|
name: "高级工程师".to_string(),
|
|
code: Some("SENIOR".to_string()),
|
|
level: Some(3),
|
|
sort_order: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_position_req_empty_name_fails() {
|
|
let req = CreatePositionReq {
|
|
name: "".to_string(),
|
|
code: None,
|
|
level: None,
|
|
sort_order: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
}
|