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