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