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(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(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    use super::*;
154
155    // ── RegisterRequest ──────────────────────────────────────────────────────
156
157    fn valid_register() -> RegisterRequest {
158        RegisterRequest {
159            email: "user@example.com".to_string(),
160            first_name: "Alice".to_string(),
161            last_name: "Smith".to_string(),
162            password: SecretString::new("password123"),
163            registration_token: None,
164        }
165    }
166
167    #[test]
168    fn register_valid() {
169        assert!(valid_register().validate().is_ok());
170    }
171
172    #[test]
173    fn register_email_too_long() {
174        let mut req = valid_register();
175        req.email = format!("{}@x.com", "a".repeat(250));
176        let err = req.validate().unwrap_err();
177        assert_eq!(err.field, "email");
178    }
179
180    #[test]
181    fn register_email_no_at_sign() {
182        let mut req = valid_register();
183        req.email = "notanemail".to_string();
184        let err = req.validate().unwrap_err();
185        assert_eq!(err.field, "email");
186    }
187
188    #[test]
189    fn register_first_name_empty() {
190        let mut req = valid_register();
191        req.first_name = String::new();
192        let err = req.validate().unwrap_err();
193        assert_eq!(err.field, "first_name");
194    }
195
196    #[test]
197    fn register_password_too_short() {
198        let mut req = valid_register();
199        req.password = SecretString::new("short");
200        let err = req.validate().unwrap_err();
201        assert_eq!(err.field, "password");
202    }
203
204    #[test]
205    fn register_password_too_long() {
206        let mut req = valid_register();
207        req.password = SecretString::new("a".repeat(1025));
208        let err = req.validate().unwrap_err();
209        assert_eq!(err.field, "password");
210    }
211
212    // ── LoginRequest ─────────────────────────────────────────────────────────
213
214    fn valid_login() -> LoginRequest {
215        LoginRequest {
216            email: "user@example.com".to_string(),
217            password: SecretString::new("password123"),
218        }
219    }
220
221    #[test]
222    fn login_valid() {
223        assert!(valid_login().validate().is_ok());
224    }
225
226    #[test]
227    fn login_email_too_long() {
228        let mut req = valid_login();
229        req.email = format!("{}@x.com", "a".repeat(250));
230        let err = req.validate().unwrap_err();
231        assert_eq!(err.field, "email");
232    }
233
234    #[test]
235    fn login_email_no_at_sign() {
236        let mut req = valid_login();
237        req.email = "notanemail".to_string();
238        let err = req.validate().unwrap_err();
239        assert_eq!(err.field, "email");
240    }
241
242    #[test]
243    fn login_password_empty() {
244        let mut req = valid_login();
245        req.password = SecretString::new(String::new());
246        let err = req.validate().unwrap_err();
247        assert_eq!(err.field, "password");
248    }
249}