1use crate::permissions::Permission;
2use crate::validation::{Validate, ValidationError};
3use serde::{Deserialize, Serialize};
4use uptrakit_shared_types::SecretString;
5use uuid::Uuid;
6
7#[derive(Serialize, Deserialize)]
8#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
9#[cfg_attr(feature = "openapi", schema(example = json!({
10 "email": "admin@example.com",
11 "first_name": "Admin",
12 "last_name": "User",
13 "password": "SecurePass123"
14})))]
15pub struct RegisterRequest {
16 #[cfg_attr(feature = "openapi", schema(example = "admin@example.com"))]
17 pub email: String,
18 #[cfg_attr(feature = "openapi", schema(example = "Admin"))]
19 pub first_name: String,
20 #[cfg_attr(feature = "openapi", schema(example = "User"))]
21 pub last_name: String,
22 #[cfg_attr(feature = "openapi", schema(example = "SecurePass123", min_length = 8))]
23 pub password: SecretString,
24 pub registration_token: Option<SecretString>,
26}
27
28#[derive(Serialize, Deserialize)]
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30#[cfg_attr(feature = "openapi", schema(example = json!({
31 "email": "admin@example.com",
32 "password": "SecurePass123"
33})))]
34pub struct LoginRequest {
35 #[cfg_attr(feature = "openapi", schema(example = "admin@example.com"))]
36 pub email: String,
37 #[cfg_attr(feature = "openapi", schema(example = "SecurePass123"))]
38 pub password: SecretString,
39}
40
41#[derive(Serialize, Deserialize)]
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43pub struct LogoutRequest {
44 #[serde(default)]
47 pub refresh_token: Option<SecretString>,
48}
49
50#[derive(Serialize, Deserialize)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52pub struct RefreshRequest {
53 #[serde(default)]
56 pub refresh_token: Option<SecretString>,
57}
58
59#[derive(Debug, Serialize, Deserialize, Clone)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61pub struct AuthResponse {
62 pub access_token: SecretString,
63 pub refresh_token: SecretString,
64 pub expires_in: i64,
65 pub token_type: String,
66 pub user: UserResponse,
67}
68
69#[derive(Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct RefreshResponse {
72 pub access_token: SecretString,
73 pub refresh_token: SecretString,
75 pub expires_in: i64,
76 pub token_type: String,
77}
78
79#[derive(Debug, Serialize, Deserialize, Clone)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81pub struct UserResponse {
82 pub id: Uuid,
83 pub email: String,
84 pub first_name: String,
85 pub last_name: String,
86 pub permissions: Vec<Permission>,
87 pub has_pending_email_change: bool,
88}
89
90impl Validate for RegisterRequest {
91 fn validate(&self) -> Result<(), ValidationError> {
92 if self.email.len() > 254 {
93 return Err(ValidationError {
94 field: "email",
95 message: "email must not exceed 254 characters".to_string(),
96 });
97 }
98 if !self.email.contains('@') {
99 return Err(ValidationError {
100 field: "email",
101 message: "email must contain '@'".to_string(),
102 });
103 }
104 if self.first_name.is_empty() {
105 return Err(ValidationError {
106 field: "first_name",
107 message: "first_name must not be empty".to_string(),
108 });
109 }
110 let password_len = self.password.expose_secret().len();
111 if password_len < 8 {
112 return Err(ValidationError {
113 field: "password",
114 message: "password must be at least 8 characters".to_string(),
115 });
116 }
117 if password_len > 1024 {
118 return Err(ValidationError {
119 field: "password",
120 message: "password must not exceed 1024 characters".to_string(),
121 });
122 }
123 Ok(())
124 }
125}
126
127impl Validate for LoginRequest {
128 fn validate(&self) -> Result<(), ValidationError> {
129 if self.email.len() > 254 {
130 return Err(ValidationError {
131 field: "email",
132 message: "email must not exceed 254 characters".to_string(),
133 });
134 }
135 if !self.email.contains('@') {
136 return Err(ValidationError {
137 field: "email",
138 message: "email must contain '@'".to_string(),
139 });
140 }
141 if self.password.expose_secret().is_empty() {
142 return Err(ValidationError {
143 field: "password",
144 message: "password must not be empty".to_string(),
145 });
146 }
147 Ok(())
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 #![expect(
154 clippy::assertions_on_result_states,
155 reason = "test assertions — is_ok/is_err provides readable failure messages"
156 )]
157 use super::*;
158
159 fn valid_register() -> RegisterRequest {
162 RegisterRequest {
163 email: "user@example.com".to_string(),
164 first_name: "Alice".to_string(),
165 last_name: "Smith".to_string(),
166 password: SecretString::new("password123"),
167 registration_token: None,
168 }
169 }
170
171 #[test]
172 fn register_valid() {
173 assert!(valid_register().validate().is_ok());
174 }
175
176 #[test]
177 fn register_email_too_long() {
178 let mut req = valid_register();
179 req.email = format!("{}@x.com", "a".repeat(250));
180 let err = req.validate().unwrap_err();
181 assert_eq!(err.field, "email");
182 }
183
184 #[test]
185 fn register_email_no_at_sign() {
186 let mut req = valid_register();
187 req.email = "notanemail".to_string();
188 let err = req.validate().unwrap_err();
189 assert_eq!(err.field, "email");
190 }
191
192 #[test]
193 fn register_first_name_empty() {
194 let mut req = valid_register();
195 req.first_name = String::new();
196 let err = req.validate().unwrap_err();
197 assert_eq!(err.field, "first_name");
198 }
199
200 #[test]
201 fn register_password_too_short() {
202 let mut req = valid_register();
203 req.password = SecretString::new("short");
204 let err = req.validate().unwrap_err();
205 assert_eq!(err.field, "password");
206 }
207
208 #[test]
209 fn register_password_too_long() {
210 let mut req = valid_register();
211 req.password = SecretString::new("a".repeat(1025));
212 let err = req.validate().unwrap_err();
213 assert_eq!(err.field, "password");
214 }
215
216 fn valid_login() -> LoginRequest {
219 LoginRequest {
220 email: "user@example.com".to_string(),
221 password: SecretString::new("password123"),
222 }
223 }
224
225 #[test]
226 fn login_valid() {
227 assert!(valid_login().validate().is_ok());
228 }
229
230 #[test]
231 fn login_email_too_long() {
232 let mut req = valid_login();
233 req.email = format!("{}@x.com", "a".repeat(250));
234 let err = req.validate().unwrap_err();
235 assert_eq!(err.field, "email");
236 }
237
238 #[test]
239 fn login_email_no_at_sign() {
240 let mut req = valid_login();
241 req.email = "notanemail".to_string();
242 let err = req.validate().unwrap_err();
243 assert_eq!(err.field, "email");
244 }
245
246 #[test]
247 fn login_password_empty() {
248 let mut req = valid_login();
249 req.password = SecretString::new(String::new());
250 let err = req.validate().unwrap_err();
251 assert_eq!(err.field, "password");
252 }
253}