Skip to main content

uptrakit_web_api_types/
auth.rs

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    /// Required when registration mode is `invite`.
25    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    /// The refresh token to revoke. Optional when the token is provided
45    /// via the `refresh_token` `HttpOnly` cookie.
46    #[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    /// The refresh token. Optional when the token is provided via the
54    /// `refresh_token` `HttpOnly` cookie.
55    #[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    /// Rotated refresh token. The previous refresh token is now invalid.
74    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    // ── RegisterRequest ──────────────────────────────────────────────────────
160
161    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    // ── LoginRequest ─────────────────────────────────────────────────────────
217
218    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}