Skip to main content

vortex_sdk/
client.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use hmac::{Hmac, Mac};
3use reqwest::Client as HttpClient;
4use serde_json::json;
5use sha2::Sha256;
6use std::collections::HashMap;
7use std::sync::OnceLock;
8use std::time::{SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11use crate::error::VortexError;
12use crate::types::*;
13
14type HmacSha256 = Hmac<Sha256>;
15
16/// Vortex Rust SDK Client
17///
18/// Provides JWT generation and Vortex API integration for Rust applications.
19/// Compatible with React providers and follows the same paradigms as other Vortex SDKs.
20pub struct VortexClient {
21    api_key: String,
22    base_url: String,
23    http_client: HttpClient,
24}
25
26impl VortexClient {
27
28    /// Transform an InvitationScope by populating scope_id from scope
29    fn transform_scope(mut scope: InvitationScope) -> InvitationScope {
30        scope.scope_id = scope.scope.clone();
31        scope
32    }
33
34    /// Transform an Invitation result by populating scopes from groups
35    fn transform_invitation(mut inv: Invitation) -> Invitation {
36        let transformed: Vec<InvitationScope> = inv.groups.into_iter().map(Self::transform_scope).collect();
37        inv.scopes = transformed.clone();
38        inv.groups = transformed;
39        inv
40    }
41
42    /// Transform a vec of Invitation results
43    fn transform_invitations(invitations: Vec<Invitation>) -> Vec<Invitation> {
44        invitations.into_iter().map(Self::transform_invitation).collect()
45    }
46
47    /// Transform a CreateInvitationRequest: translate scope params to groups
48    fn transform_create_request(mut request: CreateInvitationRequest) -> CreateInvitationRequest {
49        // Preferred: flat scope_id/scope_type/scope_name -> groups
50        if request.scope_id.is_some() && request.groups.is_none() && request.scopes.is_none() {
51            let scope_id = request.scope_id.take().unwrap();
52            let scope_type = request.scope_type_flat.take().unwrap_or_default();
53            let scope_name = request.scope_name.take().unwrap_or_default();
54            request.groups = Some(vec![CreateInvitationScope::new(&scope_type, &scope_id, &scope_name)]);
55        }
56        // Legacy: scopes -> groups
57        else if request.scopes.is_some() && request.groups.is_none() {
58            request.groups = request.scopes.take();
59        }
60        request.scope_id = None;
61        request.scope_type_flat = None;
62        request.scope_name = None;
63        request.scopes = None;
64        request
65    }
66
67    /// Create a new Vortex client
68    ///
69    /// # Arguments
70    ///
71    /// * `api_key` - Your Vortex API key
72    ///
73    /// # Example
74    ///
75    /// ```
76    /// use vortex_sdk::VortexClient;
77    ///
78    /// let api_key = "VRTX.your_encoded_id.your_key".to_string();
79    /// let client = VortexClient::new(api_key);
80    /// ```
81    pub fn new(api_key: String) -> Self {
82        let base_url = std::env::var("VORTEX_API_BASE_URL")
83            .unwrap_or_else(|_| "https://api.vortexsoftware.com".to_string());
84
85        Self {
86            api_key,
87            base_url,
88            http_client: HttpClient::new(),
89        }
90    }
91
92    /// Create a new Vortex client with a custom base URL
93    ///
94    /// # Arguments
95    ///
96    /// * `api_key` - Your Vortex API key
97    /// * `base_url` - Custom base URL for the Vortex API
98    pub fn with_base_url(api_key: String, base_url: String) -> Self {
99        Self {
100            api_key,
101            base_url,
102            http_client: HttpClient::new(),
103        }
104    }
105
106    /// Generate a JWT token for a user
107    ///
108    /// # Arguments
109    ///
110    /// * `user` - User object with id, email, and optional fields:
111    ///   - name: user's display name (max 200 characters)
112    ///   - avatar_url: user's avatar URL (must be HTTPS, max 2000 characters)
113    ///   - admin_scopes: list of admin scopes (e.g., vec!["autojoin"])
114    /// * `extra` - Optional additional properties to include in the JWT payload
115    ///
116    /// # Example
117    ///
118    /// ```
119    /// use vortex_sdk::{VortexClient, User};
120    /// use std::collections::HashMap;
121    ///
122    /// let client = VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.test_secret_key".to_string());
123    ///
124    /// // Simple usage
125    /// let user = User::new("user-123", "user@example.com")
126    ///     .with_user_name("Jane Doe")                                     // Optional: user's display name
127    ///     .with_user_avatar_url("https://example.com/avatars/jane.jpg")  // Optional: user's avatar URL
128    ///     .with_admin_scopes(vec!["autojoin".to_string()]);         // Optional: grants admin privileges
129    /// let jwt = client.generate_jwt(&user, None).unwrap();
130    ///
131    /// // With additional properties
132    /// let mut extra = HashMap::new();
133    /// extra.insert("role".to_string(), serde_json::json!("admin"));
134    /// let jwt = client.generate_jwt(&user, Some(extra)).unwrap();
135    /// ```
136    /// Sign a user object for use with the widget `signature` prop.
137    ///
138    /// Returns a signature string in `kid:hexDigest` format.
139    /// Pass this alongside the `user` prop on VortexInvite.
140    ///
141    /// # Example
142    ///
143    /// ```no_run
144    /// use vortex_sdk::{VortexClient, User};
145    /// let client = VortexClient::new("your-api-key".to_string());
146    /// let user = User::new("user-123", "user@example.com");
147    /// let signature = client.sign(&user).unwrap();
148    /// // Pass signature to frontend
149    /// ```
150    pub fn sign(&self, user: &User) -> Result<String, VortexError> {
151        let (kid, signing_key) = self.parse_and_derive_key()?;
152
153        // Build canonical payload (UnsignedData shape, sorted keys)
154        let mut canonical = serde_json::Map::new();
155        canonical.insert("userId".to_string(), json!(user.id));
156        canonical.insert("userEmail".to_string(), json!(&user.email));
157        // Prefer new property names (name/avatar_url), fall back to deprecated (user_name/user_avatar_url)
158        let user_name = user.name.as_ref().or(user.user_name.as_ref());
159        if let Some(name) = user_name {
160            canonical.insert("name".to_string(), json!(name));
161        }
162        let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
163        if let Some(avatar) = user_avatar_url {
164            canonical.insert("avatarUrl".to_string(), json!(avatar));
165        }
166        if let Some(ref scopes) = user.admin_scopes {
167            if !scopes.is_empty() {
168                canonical.insert("adminScopes".to_string(), json!(scopes));
169            }
170        }
171        if let Some(ref domains) = user.allowed_email_domains {
172            if !domains.is_empty() {
173                canonical.insert("allowedEmailDomains".to_string(), json!(domains));
174            }
175        }
176        // Include extra fields
177        if let Some(ref extra) = user.extra {
178            for (k, v) in extra {
179                if !canonical.contains_key(k) {
180                    canonical.insert(k.clone(), v.clone());
181                }
182            }
183        }
184
185        // serde_json::Map is BTreeMap — keys are already sorted
186        let canonical_json = serde_json::to_string(&serde_json::Value::Object(canonical))
187            .map_err(|e| VortexError::CryptoError(format!("Failed to serialize canonical payload: {}", e)))?;
188
189        let mut mac = HmacSha256::new_from_slice(&signing_key)
190            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
191        mac.update(canonical_json.as_bytes());
192        let digest = hex::encode(mac.finalize().into_bytes());
193
194        Ok(format!("{}:{}", kid, digest))
195    }
196
197    fn parse_and_derive_key(&self) -> Result<(String, Vec<u8>), VortexError> {
198        let parts: Vec<&str> = self.api_key.split('.').collect();
199        if parts.len() != 3 || parts[0] != "VRTX" {
200            return Err(VortexError::InvalidApiKey("Invalid API key format".to_string()));
201        }
202
203        let uuid_bytes = URL_SAFE_NO_PAD
204            .decode(parts[1])
205            .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode key ID: {}", e)))?;
206        let kid = Uuid::from_slice(&uuid_bytes)
207            .map_err(|e| VortexError::InvalidApiKey(format!("Failed to parse UUID: {}", e)))?
208            .to_string();
209
210        let mut mac = HmacSha256::new_from_slice(parts[2].as_bytes())
211            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
212        mac.update(kid.as_bytes());
213        let signing_key = mac.finalize().into_bytes().to_vec();
214
215        Ok((kid, signing_key))
216    }
217
218    /// Parse expiration time string or seconds into seconds
219    fn parse_expires_in(&self, expires_in: &ExpiresIn) -> Result<u64, VortexError> {
220        match expires_in {
221            ExpiresIn::Seconds(s) => {
222                if *s == 0 {
223                    return Err(VortexError::InvalidRequest(
224                        "expires_in must be a positive number of seconds".to_string(),
225                    ));
226                }
227                Ok(*s)
228            }
229            ExpiresIn::Duration(s) => {
230                // Use OnceLock for regex compilation (compiled once, reused)
231                static DURATION_RE: OnceLock<regex::Regex> = OnceLock::new();
232                let re = DURATION_RE.get_or_init(|| {
233                    regex::Regex::new(r"^(\d+)(m|h|d)$").expect("Invalid regex pattern")
234                });
235
236                if let Some(caps) = re.captures(s) {
237                    let value: u64 = caps[1].parse().map_err(|_| {
238                        VortexError::InvalidRequest(format!(
239                            "Invalid expires_in format: \"{}\". Value is too large.",
240                            s
241                        ))
242                    })?;
243
244                    // Reject zero duration values (e.g., "0m", "0h", "0d")
245                    if value == 0 {
246                        return Err(VortexError::InvalidRequest(
247                            "expires_in must be a positive duration".to_string(),
248                        ));
249                    }
250
251                    let unit = &caps[2];
252                    let seconds = match unit {
253                        "m" => value.checked_mul(60),
254                        "h" => value.checked_mul(3600),
255                        "d" => value.checked_mul(86400),
256                        _ => {
257                            return Err(VortexError::InvalidRequest(format!(
258                                "Unknown time unit: {}",
259                                unit
260                            )))
261                        }
262                    }
263                    .ok_or_else(|| {
264                        VortexError::InvalidRequest(format!(
265                            "expires_in value is too large: \"{}\"",
266                            s
267                        ))
268                    })?;
269
270                    Ok(seconds)
271                } else {
272                    Err(VortexError::InvalidRequest(format!(
273                        "Invalid expires_in format: \"{}\". Use \"5m\", \"1h\", \"24h\", \"7d\" or seconds.",
274                        s
275                    )))
276                }
277            }
278        }
279    }
280
281    /// Generate a signed token for use with Vortex widgets
282    ///
283    /// # Arguments
284    ///
285    /// * `payload` - Data to sign (user, component, scope, vars, etc.)
286    /// * `options` - Optional configuration (expires_in)
287    ///
288    /// # Example
289    ///
290    /// ```
291    /// use vortex_sdk::{VortexClient, GenerateTokenPayload, TokenUser};
292    ///
293    /// // Test API key: 22 A's = 16 bytes base64url-encoded UUID
294    /// let client = VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.test-secret-key".to_string());
295    /// let payload = GenerateTokenPayload::new()
296    ///     .with_user(TokenUser::new("user-123"));
297    /// let token = client.generate_token(&payload, None).unwrap();
298    /// ```
299    pub fn generate_token(
300        &self,
301        payload: &GenerateTokenPayload,
302        options: Option<&GenerateTokenOptions>,
303    ) -> Result<String, VortexError> {
304        // Warn if user.id is missing
305        if payload.user.is_none() || payload.user.as_ref().map(|u| u.id.is_empty()).unwrap_or(true) {
306            eprintln!("[Vortex SDK] Warning: signing payload without user.id means invitations won't be securely attributed.");
307        }
308
309        let (kid, signing_key) = self.parse_and_derive_key()?;
310
311        // Default 5 minutes
312        let expires_in_seconds = if let Some(opts) = options {
313            if let Some(ref exp) = opts.expires_in {
314                self.parse_expires_in(exp)?
315            } else {
316                300
317            }
318        } else {
319            300
320        };
321
322        let now = SystemTime::now()
323            .duration_since(UNIX_EPOCH)
324            .unwrap()
325            .as_secs();
326        let exp = now.checked_add(expires_in_seconds).ok_or_else(|| {
327            VortexError::InvalidRequest(
328                "expires_in is too large and would overflow the expiration timestamp".to_string(),
329            )
330        })?;
331
332        // Build JWT header
333        let header = json!({
334            "alg": "HS256",
335            "typ": "JWT",
336            "kid": kid
337        });
338
339        // Build JWT payload
340        let mut jwt_payload = serde_json::to_value(payload)
341            .map_err(|e| VortexError::CryptoError(format!("Failed to serialize payload: {}", e)))?;
342        if let Some(obj) = jwt_payload.as_object_mut() {
343            obj.insert("iat".to_string(), json!(now));
344            obj.insert("exp".to_string(), json!(exp));
345        }
346
347        // Base64URL encode
348        let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
349        let payload_b64 = URL_SAFE_NO_PAD.encode(jwt_payload.to_string().as_bytes());
350
351        // Sign
352        let to_sign = format!("{}.{}", header_b64, payload_b64);
353        let mut mac = HmacSha256::new_from_slice(&signing_key)
354            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
355        mac.update(to_sign.as_bytes());
356        let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
357
358        Ok(format!("{}.{}", to_sign, signature))
359    }
360
361    pub fn generate_jwt(
362        &self,
363        user: &User,
364        extra: Option<HashMap<String, serde_json::Value>>,
365    ) -> Result<String, VortexError> {
366        // Parse API key: format is VRTX.base64encodedId.key
367        let parts: Vec<&str> = self.api_key.split('.').collect();
368        if parts.len() != 3 {
369            return Err(VortexError::InvalidApiKey(
370                "Invalid API key format".to_string(),
371            ));
372        }
373
374        let prefix = parts[0];
375        let encoded_id = parts[1];
376        let key = parts[2];
377
378        if prefix != "VRTX" {
379            return Err(VortexError::InvalidApiKey(
380                "Invalid API key prefix".to_string(),
381            ));
382        }
383
384        // Decode the UUID from base64url
385        let id_bytes = URL_SAFE_NO_PAD
386            .decode(encoded_id)
387            .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
388
389        if id_bytes.len() != 16 {
390            return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
391        }
392
393        let uuid = Uuid::from_slice(&id_bytes)
394            .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
395        let uuid_str = uuid.to_string();
396
397        let now = SystemTime::now()
398            .duration_since(UNIX_EPOCH)
399            .unwrap()
400            .as_secs();
401        let expires = now + 3600; // 1 hour from now
402
403        // Step 1: Derive signing key from API key + ID
404        let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
405            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
406        hmac.update(uuid_str.as_bytes());
407        let signing_key = hmac.finalize().into_bytes();
408
409        // Step 2: Build header + payload
410        let header = json!({
411            "iat": now,
412            "alg": "HS256",
413            "typ": "JWT",
414            "kid": uuid_str,
415        });
416
417        // Build payload with user data
418        let mut payload_json = json!({
419            "userId": user.id,
420            "userEmail": user.email,
421            "expires": expires,
422        });
423
424        // Add name if present (prefer new property, fall back to deprecated)
425        let user_name = user.name.as_ref().or(user.user_name.as_ref());
426        if let Some(name) = user_name {
427            payload_json["name"] = json!(name);
428        }
429
430        // Add avatarUrl if present (prefer new property, fall back to deprecated)
431        let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
432        if let Some(avatar_url) = user_avatar_url {
433            payload_json["avatarUrl"] = json!(avatar_url);
434        }
435
436        // Add adminScopes if present
437        if let Some(ref scopes) = user.admin_scopes {
438            payload_json["adminScopes"] = json!(scopes);
439        }
440
441        // Add allowedEmailDomains if present (for domain-restricted invitations)
442        if let Some(ref domains) = user.allowed_email_domains {
443            if !domains.is_empty() {
444                payload_json["allowedEmailDomains"] = json!(domains);
445            }
446        }
447
448        // Add any additional properties from extra parameter
449        if let Some(extra_props) = extra {
450            for (key, value) in extra_props {
451                payload_json[key] = value;
452            }
453        }
454
455        // Step 3: Base64URL encode header and payload
456        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
457        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
458
459        // Step 4: Sign with HMAC-SHA256
460        let to_sign = format!("{}.{}", header_b64, payload_b64);
461        let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
462            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
463        sig_hmac.update(to_sign.as_bytes());
464        let signature = sig_hmac.finalize().into_bytes();
465        let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
466
467        Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
468    }
469
470    /// Get invitations by target (email or sms)
471    pub async fn get_invitations_by_target(
472        &self,
473        target_type: &str,
474        target_value: &str,
475    ) -> Result<Vec<Invitation>, VortexError> {
476        let mut params = HashMap::new();
477        params.insert("targetType", target_type);
478        params.insert("targetValue", target_value);
479
480        let response: InvitationsResponse = self
481            .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
482            .await?;
483
484        Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
485    }
486
487    /// Get a specific invitation by ID
488    pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
489        let result: Invitation = self.api_request(
490            "GET",
491            &format!("/api/v1/invitations/{}", invitation_id),
492            None::<&()>,
493            None,
494        )
495        .await?;
496        Ok(Self::transform_invitation(result))
497    }
498
499    /// Revoke (delete) an invitation
500    pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
501        self.api_request::<(), ()>(
502            "DELETE",
503            &format!("/api/v1/invitations/{}", invitation_id),
504            None,
505            None,
506        )
507        .await?;
508        Ok(())
509    }
510
511    /// Accept multiple invitations
512    ///
513    /// # Arguments
514    ///
515    /// * `invitation_ids` - Vector of invitation IDs to accept
516    /// * `param` - User data (preferred) or legacy target format
517    ///
518    /// # New User Format (Preferred)
519    ///
520    /// ```
521    /// use vortex_sdk::{VortexClient, AcceptUser};
522    ///
523    /// # async fn example() {
524    /// let client = VortexClient::new("VRTX.key.secret".to_string());
525    /// let user = AcceptUser::new().with_email("user@example.com");
526    /// let result = client.accept_invitations(vec!["inv-123".to_string()], user).await;
527    /// # }
528    /// ```
529    ///
530    /// # Legacy Target Format (Deprecated)
531    ///
532    /// ```
533    /// use vortex_sdk::{VortexClient, InvitationTarget};
534    ///
535    /// # async fn example() {
536    /// let client = VortexClient::new("VRTX.key.secret".to_string());
537    /// let target = InvitationTarget::email("user@example.com");
538    /// let result = client.accept_invitations(vec!["inv-123".to_string()], target).await;
539    /// # }
540    /// ```
541    pub async fn accept_invitations(
542        &self,
543        invitation_ids: Vec<String>,
544        param: impl Into<crate::types::AcceptInvitationParam>,
545    ) -> Result<Invitation, VortexError> {
546        use crate::types::{AcceptInvitationParam, AcceptUser};
547
548        let param = param.into();
549
550        // Convert all parameter types to User format to avoid async recursion
551        let user = match param {
552            AcceptInvitationParam::Targets(targets) => {
553                eprintln!("[Vortex SDK] DEPRECATED: Passing a vector of targets is deprecated. Use the AcceptUser format and call once per user instead.");
554
555                if targets.is_empty() {
556                    return Err(VortexError::InvalidRequest("No targets provided".to_string()));
557                }
558
559                let mut last_result = None;
560                let mut last_error = None;
561
562                for target in targets {
563                    // Convert target to user
564                    let user = match target.target_type {
565                        InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
566                        InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
567                        _ => AcceptUser::new().with_email(&target.value),
568                    };
569
570                    match Box::pin(self.accept_invitations(invitation_ids.clone(), user)).await {
571                        Ok(result) => last_result = Some(result),
572                        Err(e) => last_error = Some(e),
573                    }
574                }
575
576                if let Some(err) = last_error {
577                    return Err(err);
578                }
579
580                return last_result.ok_or_else(|| VortexError::InvalidRequest("No results".to_string()));
581            }
582            AcceptInvitationParam::Target(target) => {
583                eprintln!("[Vortex SDK] DEPRECATED: Passing an InvitationTarget is deprecated. Use the AcceptUser format instead: AcceptUser::new().with_email(\"user@example.com\")");
584
585                // Convert target to User format
586                match target.target_type {
587                    InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
588                    InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
589                    _ => AcceptUser::new().with_email(&target.value), // Default to email
590                }
591            }
592            AcceptInvitationParam::User(user) => user,
593        };
594
595        // Validate that either email or phone is provided
596        if user.email.is_none() && user.phone.is_none() {
597            return Err(VortexError::InvalidRequest(
598                "User must have either email or phone".to_string(),
599            ));
600        }
601
602        let body = json!({
603            "invitationIds": invitation_ids,
604            "user": user,
605        });
606
607        let result: Invitation = self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
608            .await?;
609        Ok(Self::transform_invitation(result))
610    }
611
612    /// Accept a single invitation (recommended method)
613    ///
614    /// This is the recommended method for accepting invitations.
615    ///
616    /// # Arguments
617    ///
618    /// * `invitation_id` - Single invitation ID to accept
619    /// * `user` - User object with email and/or phone
620    ///
621    /// # Returns
622    ///
623    /// * `Result<Invitation, VortexError>` - The accepted invitation result
624    ///
625    /// # Example
626    ///
627    /// ```
628    /// use vortex_sdk::{VortexClient, AcceptUser};
629    ///
630    /// # async fn example() {
631    /// let client = VortexClient::new("VRTX.key.secret".to_string());
632    /// let user = AcceptUser::new().with_email("user@example.com");
633    /// let result = client.accept_invitation("inv-123", user).await;
634    /// # }
635    /// ```
636    pub async fn accept_invitation(
637        &self,
638        invitation_id: &str,
639        user: crate::types::AcceptUser,
640    ) -> Result<Invitation, VortexError> {
641        self.accept_invitations(vec![invitation_id.to_string()], user).await
642    }
643
644    /// Delete all invitations for a specific group
645    pub async fn delete_invitations_by_scope(
646        &self,
647        scope_type: &str,
648        scope: &str,
649    ) -> Result<(), VortexError> {
650        self.api_request::<(), ()>(
651            "DELETE",
652            &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
653            None,
654            None,
655        )
656        .await?;
657        Ok(())
658    }
659
660    /// Get all invitations for a specific group
661    pub async fn get_invitations_by_scope(
662        &self,
663        scope_type: &str,
664        scope: &str,
665    ) -> Result<Vec<Invitation>, VortexError> {
666        let response: InvitationsResponse = self
667            .api_request(
668                "GET",
669                &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
670                None::<&()>,
671                None,
672            )
673            .await?;
674
675        Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
676    }
677
678    /// Reinvite a user (send invitation again)
679    pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
680        let result: Invitation = self.api_request(
681            "POST",
682            &format!("/api/v1/invitations/{}/reinvite", invitation_id),
683            None::<&()>,
684            None,
685        )
686        .await?;
687        Ok(Self::transform_invitation(result))
688    }
689
690    /// Create an invitation from your backend
691    ///
692    /// This method allows you to create invitations programmatically using your API key,
693    /// without requiring a user JWT token. Useful for server-side invitation creation,
694    /// such as "People You May Know" flows or admin-initiated invitations.
695    ///
696    /// # Target types
697    ///
698    /// - `email`: Send an email invitation
699    /// - `sms`: Create an SMS invitation (short link returned for you to send)
700    /// - `internal`: Create an internal invitation for PYMK flows (no email sent)
701    ///
702    /// # Example
703    ///
704    /// ```no_run
705    /// use vortex_sdk::{VortexClient, CreateInvitationRequest, CreateInvitationTarget, Inviter, CreateInvitationScope};
706    ///
707    /// #[tokio::main]
708    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
709    ///     let client = VortexClient::new("VRTX.xxx.yyy".to_string());
710    ///
711    ///     // Create an email invitation
712    ///     let request = CreateInvitationRequest::new(
713    ///         "widget-config-123",
714    ///         CreateInvitationTarget::email("invitee@example.com"),
715    ///         Inviter::new("user-456")
716    ///             .with_email("inviter@example.com")
717    ///             .with_user_name("John Doe"),
718    ///     )
719    ///     .with_groups(vec![
720    ///         CreateInvitationScope::new("team", "team-789", "Engineering"),
721    ///     ]);
722    ///
723    ///     let result = client.create_invitation(request).await?;
724    ///
725    ///     // Create an internal invitation (PYMK flow - no email sent)
726    ///     let request = CreateInvitationRequest::new(
727    ///         "widget-config-123",
728    ///         CreateInvitationTarget::internal("internal-user-abc"),
729    ///         Inviter::new("user-456"),
730    ///     )
731    ///     .with_source("pymk");
732    ///
733    ///     let result = client.create_invitation(request).await?;
734    ///     Ok(())
735    /// }
736    /// ```
737    pub async fn create_invitation(
738        &self,
739        request: CreateInvitationRequest,
740    ) -> Result<CreateInvitationResponse, VortexError> {
741        let transformed = Self::transform_create_request(request);
742        self.api_request("POST", "/api/v1/invitations", Some(&transformed), None)
743            .await
744    }
745
746    /// Get autojoin domains configured for a specific scope
747    ///
748    /// # Arguments
749    ///
750    /// * `scope_type` - The type of scope (e.g., "organization", "team", "project")
751    /// * `scope` - The scope identifier (customer's group ID)
752    ///
753    /// # Returns
754    ///
755    /// AutojoinDomainsResponse with autojoin domains and associated invitation
756    ///
757    /// # Example
758    ///
759    /// ```no_run
760    /// use vortex_sdk::VortexClient;
761    ///
762    /// #[tokio::main]
763    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
764    ///     let client = VortexClient::new("VRTX.your_key_here".to_string());
765    ///
766    ///     let result = client.get_autojoin_domains("organization", "acme-org").await?;
767    ///     for domain in &result.autojoin_domains {
768    ///         println!("Domain: {}", domain.domain);
769    ///     }
770    ///     Ok(())
771    /// }
772    /// ```
773    pub async fn get_autojoin_domains(
774        &self,
775        scope_type: &str,
776        scope: &str,
777    ) -> Result<AutojoinDomainsResponse, VortexError> {
778        let encoded_scope_type = urlencoding::encode(scope_type);
779        let encoded_scope = urlencoding::encode(scope);
780        let path = format!(
781            "/api/v1/invitations/by-scope/{}/{}/autojoin",
782            encoded_scope_type, encoded_scope
783        );
784        self.api_request::<AutojoinDomainsResponse, ()>("GET", &path, None, None)
785            .await
786    }
787
788    /// Configure autojoin domains for a specific scope
789    ///
790    /// This endpoint syncs autojoin domains - it will add new domains, remove domains
791    /// not in the provided list, and deactivate the autojoin invitation if all domains
792    /// are removed (empty array).
793    ///
794    /// # Arguments
795    ///
796    /// * `request` - The configure autojoin request
797    ///
798    /// # Returns
799    ///
800    /// AutojoinDomainsResponse with updated autojoin domains and associated invitation
801    ///
802    /// # Example
803    ///
804    /// ```no_run
805    /// use vortex_sdk::{VortexClient, ConfigureAutojoinRequest};
806    ///
807    /// #[tokio::main]
808    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
809    ///     let client = VortexClient::new("VRTX.your_key_here".to_string());
810    ///
811    ///     let request = ConfigureAutojoinRequest::new(
812    ///         "acme-org",
813    ///         "organization",
814    ///         vec!["acme.com".to_string(), "acme.org".to_string()],
815    ///         "widget-123",
816    ///     )
817    ///     .with_scope_name("Acme Corporation");
818    ///
819    ///     let result = client.configure_autojoin(&request).await?;
820    ///     Ok(())
821    /// }
822    /// ```
823    ///
824    /// Sync an internal invitation action (accept or decline)
825    ///
826    /// This method notifies Vortex that an internal invitation was accepted or declined
827    /// within your application, so Vortex can update the invitation status accordingly.
828    ///
829    /// # Example
830    ///
831    /// ```no_run
832    /// use vortex_sdk::{VortexClient, SyncInternalInvitationRequest};
833    ///
834    /// #[tokio::main]
835    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
836    ///     let client = VortexClient::new("VRTX.xxx.yyy".to_string());
837    ///     let request = SyncInternalInvitationRequest::new(
838    ///         "user-123", "user-456", "accepted", "component-uuid",
839    ///     );
840    ///     let result = client.sync_internal_invitation(&request).await?;
841    ///     println!("Processed {} invitations", result.processed);
842    ///     Ok(())
843    /// }
844    /// ```
845    pub async fn sync_internal_invitation(
846        &self,
847        request: &SyncInternalInvitationRequest,
848    ) -> Result<SyncInternalInvitationResponse, VortexError> {
849        self.api_request(
850            "POST",
851            "/api/v1/invitations/sync-internal-invitation",
852            Some(request),
853            None,
854        )
855        .await
856    }
857
858    pub async fn configure_autojoin(
859        &self,
860        request: &ConfigureAutojoinRequest,
861    ) -> Result<AutojoinDomainsResponse, VortexError> {
862        let mut result: AutojoinDomainsResponse = self.api_request("POST", "/api/v1/invitations/autojoin", Some(request), None)
863            .await?;
864        if let Some(inv) = result.invitation.take() {
865            result.invitation = Some(Self::transform_invitation(inv));
866        }
867        Ok(result)
868    }
869
870    async fn api_request<T, B>(
871        &self,
872        method: &str,
873        path: &str,
874        body: Option<&B>,
875        query_params: Option<HashMap<&str, &str>>,
876    ) -> Result<T, VortexError>
877    where
878        T: serde::de::DeserializeOwned,
879        B: serde::Serialize,
880    {
881        let url = format!("{}{}", self.base_url, path);
882
883        let mut request = match method {
884            "GET" => self.http_client.get(&url),
885            "POST" => self.http_client.post(&url),
886            "PUT" => self.http_client.put(&url),
887            "DELETE" => self.http_client.delete(&url),
888            _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
889        };
890
891        // Add headers
892        request = request
893            .header("Content-Type", "application/json")
894            .header("x-api-key", &self.api_key)
895            .header("User-Agent", format!("vortex-rust-sdk/{}", env!("CARGO_PKG_VERSION")))
896            .header("x-vortex-sdk-name", "vortex-rust-sdk")
897            .header("x-vortex-sdk-version", env!("CARGO_PKG_VERSION"));
898
899        // Add query parameters
900        if let Some(params) = query_params {
901            request = request.query(&params);
902        }
903
904        // Add body
905        if let Some(b) = body {
906            request = request.json(b);
907        }
908
909        let response = request
910            .send()
911            .await
912            .map_err(|e| VortexError::HttpError(e.to_string()))?;
913
914        if !response.status().is_success() {
915            let status = response.status();
916            let error_text = response
917                .text()
918                .await
919                .unwrap_or_else(|_| "Unknown error".to_string());
920            return Err(VortexError::ApiError(format!(
921                "API request failed: {} - {}",
922                status, error_text
923            )));
924        }
925
926        let text = response
927            .text()
928            .await
929            .map_err(|e| VortexError::HttpError(e.to_string()))?;
930
931        // Handle empty responses
932        if text.is_empty() {
933            return serde_json::from_str("{}")
934                .map_err(|e| VortexError::SerializationError(e.to_string()));
935        }
936
937        serde_json::from_str(&text)
938            .map_err(|e| VortexError::SerializationError(e.to_string()))
939    }
940
941    // Deprecated methods for backward compatibility
942
943    /// Get invitations by group (deprecated: use get_invitations_by_scope instead)
944    #[deprecated(since = "0.1.0", note = "Use get_invitations_by_scope instead")]
945    pub async fn get_invitations_by_group(
946        &self,
947        group_type: &str,
948        group: &str,
949    ) -> Result<Vec<Invitation>, VortexError> {
950        self.get_invitations_by_scope(group_type, group).await
951    }
952
953    /// Delete invitations by group (deprecated: use delete_invitations_by_scope instead)
954    #[deprecated(since = "0.1.0", note = "Use delete_invitations_by_scope instead")]
955    pub async fn delete_invitations_by_group(
956        &self,
957        group_type: &str,
958        group: &str,
959    ) -> Result<(), VortexError> {
960        self.delete_invitations_by_scope(group_type, group).await
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    fn test_client() -> VortexClient {
969        // Valid API key format: VRTX.<base64url-encoded-16-byte-uuid>.<key>
970        // Using a dummy key for testing
971        VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.testkey".to_string())
972    }
973
974    #[test]
975    fn test_parse_expires_in_seconds() {
976        let client = test_client();
977        assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(300)).unwrap(), 300);
978        assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(3600)).unwrap(), 3600);
979    }
980
981    #[test]
982    fn test_parse_expires_in_zero_seconds_rejected() {
983        let client = test_client();
984        let result = client.parse_expires_in(&ExpiresIn::Seconds(0));
985        assert!(result.is_err());
986        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
987    }
988
989    #[test]
990    fn test_parse_expires_in_minutes() {
991        let client = test_client();
992        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("5m".to_string())).unwrap(), 300);
993        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("60m".to_string())).unwrap(), 3600);
994    }
995
996    #[test]
997    fn test_parse_expires_in_hours() {
998        let client = test_client();
999        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1h".to_string())).unwrap(), 3600);
1000        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("24h".to_string())).unwrap(), 86400);
1001    }
1002
1003    #[test]
1004    fn test_parse_expires_in_days() {
1005        let client = test_client();
1006        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1d".to_string())).unwrap(), 86400);
1007        assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("7d".to_string())).unwrap(), 604800);
1008    }
1009
1010    #[test]
1011    fn test_parse_expires_in_zero_duration_rejected() {
1012        let client = test_client();
1013        
1014        let result = client.parse_expires_in(&ExpiresIn::Duration("0m".to_string()));
1015        assert!(result.is_err());
1016        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1017
1018        let result = client.parse_expires_in(&ExpiresIn::Duration("0h".to_string()));
1019        assert!(result.is_err());
1020        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1021
1022        let result = client.parse_expires_in(&ExpiresIn::Duration("0d".to_string()));
1023        assert!(result.is_err());
1024        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1025    }
1026
1027    #[test]
1028    fn test_parse_expires_in_overflow_rejected() {
1029        let client = test_client();
1030        
1031        // u64::MAX / 60 would overflow when multiplied by 60 for minutes
1032        let large_value = format!("{}m", u64::MAX);
1033        let result = client.parse_expires_in(&ExpiresIn::Duration(large_value));
1034        assert!(result.is_err());
1035        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1036
1037        // Very large number that would overflow
1038        let result = client.parse_expires_in(&ExpiresIn::Duration("999999999999999999999d".to_string()));
1039        assert!(result.is_err());
1040    }
1041
1042    #[test]
1043    fn test_parse_expires_in_invalid_format() {
1044        let client = test_client();
1045        
1046        let result = client.parse_expires_in(&ExpiresIn::Duration("5s".to_string()));
1047        assert!(result.is_err());
1048        assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1049
1050        let result = client.parse_expires_in(&ExpiresIn::Duration("invalid".to_string()));
1051        assert!(result.is_err());
1052
1053        let result = client.parse_expires_in(&ExpiresIn::Duration("".to_string()));
1054        assert!(result.is_err());
1055    }
1056
1057    #[test]
1058    fn test_generate_token_basic() {
1059        let client = test_client();
1060        let payload = GenerateTokenPayload::new()
1061            .with_user(TokenUser::new("user-123"));
1062        
1063        let result = client.generate_token(&payload, None);
1064        assert!(result.is_ok());
1065        
1066        let token = result.unwrap();
1067        // JWT format: header.payload.signature
1068        assert_eq!(token.split('.').count(), 3);
1069    }
1070
1071    #[test]
1072    fn test_generate_token_with_expires_in() {
1073        let client = test_client();
1074        let payload = GenerateTokenPayload::new()
1075            .with_user(TokenUser::new("user-123"));
1076        let options = GenerateTokenOptions::new()
1077            .with_expires_in("24h");
1078        
1079        let result = client.generate_token(&payload, Some(&options));
1080        assert!(result.is_ok());
1081    }
1082
1083    #[test]
1084    fn test_expires_in_from_string() {
1085        let expires: ExpiresIn = String::from("24h").into();
1086        assert!(matches!(expires, ExpiresIn::Duration(s) if s == "24h"));
1087    }
1088
1089    #[test]
1090    fn test_expires_in_from_u32() {
1091        let expires: ExpiresIn = 300u32.into();
1092        assert!(matches!(expires, ExpiresIn::Seconds(300)));
1093    }
1094
1095    #[test]
1096    fn test_expires_in_from_i32() {
1097        let expires: ExpiresIn = 300i32.into();
1098        assert!(matches!(expires, ExpiresIn::Seconds(300)));
1099    }
1100}