diff --git a/torii-core/src/crypto.rs b/torii-core/src/crypto.rs index 9e4ecfa..ef8b4a8 100644 --- a/torii-core/src/crypto.rs +++ b/torii-core/src/crypto.rs @@ -22,9 +22,33 @@ //! //! See: +use rand::{TryRngCore, rngs::OsRng}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +/// Generate a cryptographically secure random token. +/// +/// This produces a 256-bit (32-byte) random token encoded as URL-safe base64. +/// The token has sufficient entropy for security-critical applications like +/// magic links, password reset tokens, and invitation tokens. +/// +/// # Returns +/// +/// A URL-safe base64-encoded random token (43 characters) +/// +/// # Panics +/// +/// Panics if the OS random number generator fails. This indicates a critical +/// system failure (e.g., /dev/urandom unavailable) from which recovery is not +/// possible for security-sensitive operations. +pub fn generate_secure_token() -> String { + let mut bytes = [0u8; 32]; // 256 bits of entropy + OsRng + .try_fill_bytes(&mut bytes) + .expect("OS RNG failure - system entropy source unavailable"); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) +} + /// Hash a token for secure storage using SHA256. /// /// This produces a deterministic hash that can be used for database lookups. diff --git a/torii-core/src/invitation.rs b/torii-core/src/invitation.rs new file mode 100644 index 0000000..6249f3a --- /dev/null +++ b/torii-core/src/invitation.rs @@ -0,0 +1,502 @@ +//! Invitation management for user onboarding +//! +//! This module provides types and functionality for inviting users to the system. +//! Invitations allow existing users to invite new users by email, creating a provisional +//! user record that can be referenced (e.g., for sharing resources) before the invitee +//! has completed signup. +//! +//! # Workflow +//! +//! 1. An inviter creates an invitation for an email address +//! 2. A provisional user is created (status = `Provisional`) +//! 3. An invitation token is generated and sent to the invitee +//! 4. The invitee clicks the link and completes signup with any auth method +//! 5. The invitation is marked as accepted and the user becomes active + +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::{ + Error, UserId, + error::ValidationError, + id::{generate_prefixed_id, validate_prefixed_id}, +}; + +/// A unique identifier for an invitation. +/// +/// Invitation IDs are prefixed with `inv_` followed by a base58-encoded random string. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct InvitationId(String); + +impl InvitationId { + /// Create a new InvitationId from an existing string. + pub fn new(id: &str) -> Self { + InvitationId(id.to_string()) + } + + /// Generate a new random invitation ID. + pub fn new_random() -> Self { + InvitationId(generate_prefixed_id("inv")) + } + + /// Convert to the inner string, consuming self. + pub fn into_inner(self) -> String { + self.0 + } + + /// Get the ID as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Validate that this ID has the correct format for an invitation ID. + pub fn is_valid(&self) -> bool { + validate_prefixed_id(&self.0, "inv") + } +} + +impl Default for InvitationId { + fn default() -> Self { + Self::new_random() + } +} + +impl From for InvitationId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for InvitationId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl std::fmt::Display for InvitationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for InvitationId { + type Err = Error; + + fn from_str(s: &str) -> Result { + let id = InvitationId(s.to_string()); + if id.is_valid() { + Ok(id) + } else { + Err(ValidationError::InvalidField(format!( + "Invalid invitation ID format: expected 'inv_' prefix with valid base58 data, got '{}'", + s + )) + .into()) + } + } +} + +/// The status of an invitation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InvitationStatus { + /// Invitation has been sent but not yet accepted + Pending, + /// Invitation has been accepted by the invitee + Accepted, + /// Invitation has expired (past expiration date) + Expired, + /// Invitation was revoked by the inviter or admin + Revoked, +} + +impl InvitationStatus { + /// Get the string representation for storage. + pub fn as_str(&self) -> &'static str { + match self { + InvitationStatus::Pending => "pending", + InvitationStatus::Accepted => "accepted", + InvitationStatus::Expired => "expired", + InvitationStatus::Revoked => "revoked", + } + } +} + +impl FromStr for InvitationStatus { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(InvitationStatus::Pending), + "accepted" => Ok(InvitationStatus::Accepted), + "expired" => Ok(InvitationStatus::Expired), + "revoked" => Ok(InvitationStatus::Revoked), + _ => { + Err(ValidationError::InvalidField(format!("Invalid invitation status: {s}")).into()) + } + } + } +} + +impl std::fmt::Display for InvitationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// An invitation to join the system. +/// +/// Invitations are created when an existing user invites a new user by email. +/// The invitation contains a secure token that the invitee uses to complete signup. +/// +/// # Security +/// +/// The token is stored as a SHA256 hash in the database. The plaintext token +/// is only available when the invitation is first created and should be sent +/// to the invitee (typically via email). +#[derive(Clone)] +pub struct Invitation { + /// Unique identifier for this invitation + pub id: InvitationId, + + /// Email address the invitation was sent to + pub email: String, + + /// The plaintext token (only available when created, not when loaded from storage) + token: Option, + + /// SHA256 hash of the token (stored in database) + pub token_hash: String, + + /// User ID of the person who sent the invitation (if known) + pub inviter_id: Option, + + /// Current status of the invitation + pub status: InvitationStatus, + + /// Application-specific metadata (roles, permissions, team assignments, etc.) + pub metadata: Option, + + /// When the invitation expires + pub expires_at: DateTime, + + /// When the invitation was accepted (if accepted) + pub accepted_at: Option>, + + /// The user ID of the account that accepted the invitation + pub accepted_by: Option, + + /// When the invitation was revoked (if revoked) + pub revoked_at: Option>, + + /// When the invitation was created + pub created_at: DateTime, + + /// When the invitation was last updated + pub updated_at: DateTime, +} + +impl Invitation { + /// Create a new Invitation with both plaintext token and hash. + /// + /// This constructor is used when creating a new invitation where both + /// the plaintext (to send to invitee) and hash (to store) are available. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: InvitationId, + email: String, + token: String, + token_hash: String, + inviter_id: Option, + metadata: Option, + expires_at: DateTime, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + email, + token: Some(SecretString::from(token)), + token_hash, + inviter_id, + status: InvitationStatus::Pending, + metadata, + expires_at, + accepted_at: None, + accepted_by: None, + revoked_at: None, + created_at, + updated_at, + } + } + + /// Create an Invitation from stored data (hash only, no plaintext). + /// + /// This constructor is used when loading an invitation from storage where + /// only the hash is available (plaintext is never stored). + #[allow(clippy::too_many_arguments)] + pub fn from_storage( + id: InvitationId, + email: String, + token_hash: String, + inviter_id: Option, + status: InvitationStatus, + metadata: Option, + expires_at: DateTime, + accepted_at: Option>, + accepted_by: Option, + revoked_at: Option>, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + email, + token: None, + token_hash, + inviter_id, + status, + metadata, + expires_at, + accepted_at, + accepted_by, + revoked_at, + created_at, + updated_at, + } + } + + /// Get the plaintext token value. + /// + /// This is only available when the invitation was just created. + /// Returns `None` when loaded from storage. + pub fn token(&self) -> Option<&str> { + self.token.as_ref().map(|s| s.expose_secret()) + } + + /// Verify a plaintext token against this invitation's hash. + pub fn verify(&self, token: &str) -> bool { + crate::crypto::verify_token_hash(token, &self.token_hash) + } + + /// Check if the invitation has expired. + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + /// Check if the invitation is still pending and not expired. + pub fn is_valid(&self) -> bool { + self.status == InvitationStatus::Pending && !self.is_expired() + } + + /// Check if the invitation can be accepted. + pub fn can_accept(&self) -> bool { + self.is_valid() + } +} + +impl std::fmt::Debug for Invitation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Invitation") + .field("id", &self.id) + .field("email", &self.email) + .field("token", &"[REDACTED]") + .field("token_hash", &self.token_hash) + .field("inviter_id", &self.inviter_id) + .field("status", &self.status) + .field("metadata", &self.metadata) + .field("expires_at", &self.expires_at) + .field("accepted_at", &self.accepted_at) + .field("accepted_by", &self.accepted_by) + .field("revoked_at", &self.revoked_at) + .field("created_at", &self.created_at) + .field("updated_at", &self.updated_at) + .finish() + } +} + +/// Data required to create a new invitation. +#[derive(Debug, Clone)] +pub struct NewInvitation { + /// Email address to invite + pub email: String, + + /// User ID of the inviter (optional) + pub inviter_id: Option, + + /// Application-specific metadata + pub metadata: Option, + + /// When the invitation expires + pub expires_at: DateTime, +} + +impl NewInvitation { + /// Create a new invitation builder. + pub fn builder() -> NewInvitationBuilder { + NewInvitationBuilder::default() + } +} + +/// Builder for creating new invitations. +#[derive(Default)] +pub struct NewInvitationBuilder { + email: Option, + inviter_id: Option, + metadata: Option, + expires_at: Option>, +} + +impl NewInvitationBuilder { + /// Set the email address to invite. + pub fn email(mut self, email: String) -> Self { + self.email = Some(email); + self + } + + /// Set the inviter's user ID. + pub fn inviter_id(mut self, inviter_id: UserId) -> Self { + self.inviter_id = Some(inviter_id); + self + } + + /// Set application-specific metadata. + pub fn metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Set when the invitation expires. + pub fn expires_at(mut self, expires_at: DateTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Build the new invitation. + pub fn build(self) -> Result { + use crate::error::utilities::RequiredFieldExt; + + Ok(NewInvitation { + email: self.email.require_field("email")?, + inviter_id: self.inviter_id, + metadata: self.metadata, + expires_at: self.expires_at.require_field("expires_at")?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_invitation_id_generation() { + let id = InvitationId::new_random(); + assert!(id.as_str().starts_with("inv_")); + assert!(id.is_valid()); + } + + #[test] + fn test_invitation_id_uniqueness() { + let id1 = InvitationId::new_random(); + let id2 = InvitationId::new_random(); + assert_ne!(id1, id2); + } + + #[test] + fn test_invitation_id_from_str() { + let id = InvitationId::new_random(); + let parsed: InvitationId = id.as_str().parse().unwrap(); + assert_eq!(id, parsed); + } + + #[test] + fn test_invitation_id_invalid() { + let result: Result = "invalid".parse(); + assert!(result.is_err()); + + let result: Result = "usr_abc".parse(); + assert!(result.is_err()); + } + + #[test] + fn test_invitation_status_roundtrip() { + for status in [ + InvitationStatus::Pending, + InvitationStatus::Accepted, + InvitationStatus::Expired, + InvitationStatus::Revoked, + ] { + let s = status.as_str(); + let parsed: InvitationStatus = s.parse().unwrap(); + assert_eq!(status, parsed); + } + } + + #[test] + fn test_invitation_is_expired() { + use chrono::Duration; + + let now = Utc::now(); + + // Not expired + let invitation = Invitation::new( + InvitationId::new_random(), + "test@example.com".to_string(), + "token".to_string(), + "hash".to_string(), + None, + None, + now + Duration::hours(24), + now, + now, + ); + assert!(!invitation.is_expired()); + assert!(invitation.is_valid()); + + // Expired + let expired_invitation = Invitation::new( + InvitationId::new_random(), + "test@example.com".to_string(), + "token".to_string(), + "hash".to_string(), + None, + None, + now - Duration::hours(1), + now - Duration::hours(25), + now - Duration::hours(25), + ); + assert!(expired_invitation.is_expired()); + assert!(!expired_invitation.is_valid()); + } + + #[test] + fn test_new_invitation_builder() { + use chrono::Duration; + + let expires_at = Utc::now() + Duration::days(7); + let invitation = NewInvitation::builder() + .email("test@example.com".to_string()) + .inviter_id(UserId::new_random()) + .expires_at(expires_at) + .build() + .unwrap(); + + assert_eq!(invitation.email, "test@example.com"); + assert!(invitation.inviter_id.is_some()); + assert_eq!(invitation.expires_at, expires_at); + } + + #[test] + fn test_new_invitation_builder_missing_fields() { + let result = NewInvitation::builder().build(); + assert!(result.is_err()); + + let result = NewInvitation::builder() + .email("test@example.com".to_string()) + .build(); + assert!(result.is_err()); // Missing expires_at + } +} diff --git a/torii-core/src/lib.rs b/torii-core/src/lib.rs index 660d9c1..c3eecb7 100644 --- a/torii-core/src/lib.rs +++ b/torii-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod crypto; pub mod error; pub mod events; pub mod id; +pub mod invitation; pub mod repositories; pub mod services; pub mod session; @@ -21,10 +22,12 @@ pub mod validation; pub use error::Error; pub use events::UnlockReason; +pub use invitation::{Invitation, InvitationId, InvitationStatus, NewInvitation}; pub use repositories::RepositoryProvider; pub use services::{ - BruteForceProtectionService, EmailVerificationService, MagicLinkService, OAuthService, - PasskeyService, PasswordResetService, PasswordService, SessionService, UserService, + BruteForceProtectionService, EmailVerificationService, InvitationConfig, InvitationService, + MagicLinkService, OAuthService, PasskeyService, PasswordResetService, PasswordService, + SessionService, UserService, }; #[cfg(feature = "mailer")] pub use services::{MailerService, ToriiMailerService}; @@ -36,4 +39,4 @@ pub use storage::{ AttemptStats, BruteForceProtectionConfig, FailedLoginAttempt, LockoutStatus, NewUser, SecureToken, TokenPurpose, }; -pub use user::{OAuthAccount, User, UserId, UserManager}; +pub use user::{OAuthAccount, User, UserId, UserManager, UserStatus}; diff --git a/torii-core/src/repositories/adapter.rs b/torii-core/src/repositories/adapter.rs index 37c81d7..5dc5f3f 100644 --- a/torii-core/src/repositories/adapter.rs +++ b/torii-core/src/repositories/adapter.rs @@ -4,13 +4,13 @@ //! repository traits are expected. This is useful for dependency injection in services. use crate::{ - Error, OAuthAccount, Session, User, UserId, + Error, Invitation, InvitationId, InvitationStatus, OAuthAccount, Session, User, UserId, repositories::{ - BruteForceProtectionRepository, BruteForceRepositoryProvider, OAuthRepository, - OAuthRepositoryProvider, PasskeyCredential, PasskeyRepository, PasskeyRepositoryProvider, - PasswordRepository, PasswordRepositoryProvider, SessionRepository, - SessionRepositoryProvider, TokenRepository, TokenRepositoryProvider, UserRepository, - UserRepositoryProvider, + BruteForceProtectionRepository, BruteForceRepositoryProvider, InvitationRepository, + InvitationRepositoryProvider, OAuthRepository, OAuthRepositoryProvider, PasskeyCredential, + PasskeyRepository, PasskeyRepositoryProvider, PasswordRepository, + PasswordRepositoryProvider, SessionRepository, SessionRepositoryProvider, TokenRepository, + TokenRepositoryProvider, UserRepository, UserRepositoryProvider, }, session::SessionToken, storage::{AttemptStats, FailedLoginAttempt, NewUser, SecureToken, TokenPurpose}, @@ -400,3 +400,78 @@ impl BruteForceProtectionRepository self.provider.brute_force().get_locked_at(email).await } } + +/// Adapter that wraps an InvitationRepositoryProvider and implements InvitationRepository. +pub struct InvitationRepositoryAdapter { + provider: Arc, +} + +impl InvitationRepositoryAdapter { + pub fn new(provider: Arc) -> Self { + Self { provider } + } +} + +#[async_trait] +impl InvitationRepository for InvitationRepositoryAdapter { + async fn create(&self, invitation: &Invitation) -> Result { + self.provider.invitation().create(invitation).await + } + + async fn find_by_id(&self, id: &InvitationId) -> Result, Error> { + self.provider.invitation().find_by_id(id).await + } + + async fn find_by_token_hash(&self, token_hash: &str) -> Result, Error> { + self.provider + .invitation() + .find_by_token_hash(token_hash) + .await + } + + async fn find_by_email(&self, email: &str) -> Result, Error> { + self.provider.invitation().find_by_email(email).await + } + + async fn find_pending_by_email(&self, email: &str) -> Result, Error> { + self.provider + .invitation() + .find_pending_by_email(email) + .await + } + + async fn find_by_inviter(&self, inviter_id: &UserId) -> Result, Error> { + self.provider.invitation().find_by_inviter(inviter_id).await + } + + async fn update_status( + &self, + id: &InvitationId, + status: InvitationStatus, + ) -> Result { + self.provider.invitation().update_status(id, status).await + } + + async fn accept(&self, id: &InvitationId, accepted_by: &UserId) -> Result { + self.provider.invitation().accept(id, accepted_by).await + } + + async fn revoke(&self, id: &InvitationId) -> Result { + self.provider.invitation().revoke(id).await + } + + async fn delete(&self, id: &InvitationId) -> Result<(), Error> { + self.provider.invitation().delete(id).await + } + + async fn cleanup_expired(&self) -> Result { + self.provider.invitation().cleanup_expired().await + } + + async fn count_pending_by_email(&self, email: &str) -> Result { + self.provider + .invitation() + .count_pending_by_email(email) + .await + } +} diff --git a/torii-core/src/repositories/invitation.rs b/torii-core/src/repositories/invitation.rs new file mode 100644 index 0000000..26271eb --- /dev/null +++ b/torii-core/src/repositories/invitation.rs @@ -0,0 +1,73 @@ +//! Repository trait for invitation data access. + +use async_trait::async_trait; + +use crate::{Error, Invitation, InvitationId, InvitationStatus, UserId}; + +/// Repository for invitation data access. +/// +/// This trait defines the operations for managing invitations in storage. +/// Implementations should handle the underlying database operations. +#[async_trait] +pub trait InvitationRepository: Send + Sync + 'static { + /// Create a new invitation. + /// + /// The invitation should include the token hash, not the plaintext token. + async fn create(&self, invitation: &Invitation) -> Result; + + /// Find an invitation by its ID. + async fn find_by_id(&self, id: &InvitationId) -> Result, Error>; + + /// Find an invitation by its token hash. + /// + /// This is used during token verification to look up the invitation. + async fn find_by_token_hash(&self, token_hash: &str) -> Result, Error>; + + /// Find all invitations for an email address. + /// + /// Returns all invitations (regardless of status) for the given email, + /// ordered by creation date descending (newest first). + async fn find_by_email(&self, email: &str) -> Result, Error>; + + /// Find all pending invitations for an email address. + /// + /// Returns only pending (not accepted, revoked, or expired) invitations. + async fn find_pending_by_email(&self, email: &str) -> Result, Error>; + + /// Find all invitations sent by a specific user. + async fn find_by_inviter(&self, inviter_id: &UserId) -> Result, Error>; + + /// Update an invitation's status. + /// + /// This is used to mark invitations as accepted, revoked, etc. + async fn update_status( + &self, + id: &InvitationId, + status: InvitationStatus, + ) -> Result; + + /// Mark an invitation as accepted. + /// + /// This updates the status to `Accepted`, sets `accepted_at` to now, + /// and records the user ID that accepted it. + async fn accept(&self, id: &InvitationId, accepted_by: &UserId) -> Result; + + /// Mark an invitation as revoked. + /// + /// This updates the status to `Revoked` and sets `revoked_at` to now. + async fn revoke(&self, id: &InvitationId) -> Result; + + /// Delete an invitation. + async fn delete(&self, id: &InvitationId) -> Result<(), Error>; + + /// Clean up expired invitations. + /// + /// This marks all pending invitations past their expiration as expired, + /// or optionally deletes them entirely. + async fn cleanup_expired(&self) -> Result; + + /// Count pending invitations for an email. + /// + /// Useful for implementing invitation limits per email. + async fn count_pending_by_email(&self, email: &str) -> Result; +} diff --git a/torii-core/src/repositories/mod.rs b/torii-core/src/repositories/mod.rs index 1e5b2b3..82e2686 100644 --- a/torii-core/src/repositories/mod.rs +++ b/torii-core/src/repositories/mod.rs @@ -18,6 +18,7 @@ pub mod adapter; pub mod brute_force; +pub mod invitation; pub mod oauth; pub mod passkey; pub mod password; @@ -26,11 +27,12 @@ pub mod token; pub mod user; pub use adapter::{ - BruteForceProtectionRepositoryAdapter, OAuthRepositoryAdapter, PasskeyRepositoryAdapter, - PasswordRepositoryAdapter, SessionRepositoryAdapter, TokenRepositoryAdapter, - UserRepositoryAdapter, + BruteForceProtectionRepositoryAdapter, InvitationRepositoryAdapter, OAuthRepositoryAdapter, + PasskeyRepositoryAdapter, PasswordRepositoryAdapter, SessionRepositoryAdapter, + TokenRepositoryAdapter, UserRepositoryAdapter, }; pub use brute_force::BruteForceProtectionRepository; +pub use invitation::InvitationRepository; pub use oauth::OAuthRepository; pub use passkey::{PasskeyCredential, PasskeyRepository}; pub use password::PasswordRepository; @@ -123,6 +125,17 @@ pub trait BruteForceRepositoryProvider: Send + Sync + 'static { fn brute_force(&self) -> &Self::BruteForceRepo; } +/// Provider trait for invitation repository access. +/// +/// Implement this trait to provide user invitation functionality. +pub trait InvitationRepositoryProvider: Send + Sync + 'static { + /// The invitation repository implementation type + type InvitationRepo: InvitationRepository; + + /// Get the invitation repository + fn invitation(&self) -> &Self::InvitationRepo; +} + // ============================================================================ // Unified Repository Provider Trait // ============================================================================ @@ -168,6 +181,7 @@ pub trait RepositoryProvider: + PasskeyRepositoryProvider + TokenRepositoryProvider + BruteForceRepositoryProvider + + InvitationRepositoryProvider { /// Run migrations for all repositories async fn migrate(&self) -> Result<(), Error>; diff --git a/torii-core/src/services/invitation.rs b/torii-core/src/services/invitation.rs new file mode 100644 index 0000000..ec30059 --- /dev/null +++ b/torii-core/src/services/invitation.rs @@ -0,0 +1,772 @@ +//! Invitation service for user onboarding +//! +//! This service handles the creation and management of user invitations, +//! including creating provisional users and managing invitation tokens. + +use std::sync::Arc; + +use chrono::{Duration, Utc}; + +use crate::{ + Error, Invitation, InvitationId, User, UserId, + crypto::{generate_secure_token, hash_token}, + error::AuthError, + repositories::{InvitationRepository, UserRepository}, + storage::NewUser, + user::UserStatus, + validation::validate_email, +}; + +/// Configuration for the invitation service. +#[derive(Debug, Clone)] +pub struct InvitationConfig { + /// How long invitations are valid before expiring. + /// Default: 7 days + pub expires_in: Duration, + + /// Whether to create a provisional user when an invitation is created. + /// If true, a user record with `status = Provisional` is created immediately. + /// If false, no user is created until the invitation is accepted. + /// Default: true + pub create_provisional_user: bool, + + /// Maximum number of pending invitations per email address. + /// If exceeded, new invitations will fail. + /// Default: 5 + pub max_pending_per_email: u64, +} + +impl Default for InvitationConfig { + fn default() -> Self { + Self { + expires_in: Duration::days(7), + create_provisional_user: true, + max_pending_per_email: 5, + } + } +} + +/// Service for managing user invitations. +/// +/// This service handles: +/// - Creating invitations and optional provisional users +/// - Validating and accepting invitation tokens +/// - Revoking invitations +/// - Listing pending invitations +pub struct InvitationService { + invitation_repository: Arc, + user_repository: Arc, + config: InvitationConfig, +} + +impl InvitationService { + /// Create a new InvitationService with the given repositories and default config. + pub fn new(invitation_repository: Arc, user_repository: Arc) -> Self { + Self { + invitation_repository, + user_repository, + config: InvitationConfig::default(), + } + } + + /// Create a new InvitationService with custom configuration. + pub fn with_config( + invitation_repository: Arc, + user_repository: Arc, + config: InvitationConfig, + ) -> Self { + Self { + invitation_repository, + user_repository, + config, + } + } + + /// Create a new invitation. + /// + /// This will: + /// 1. Check if the email already has too many pending invitations + /// 2. Optionally create a provisional user (if configured and user doesn't exist) + /// 3. Generate a secure invitation token + /// 4. Store the invitation + /// + /// Returns the invitation with the plaintext token (for sending to the invitee). + /// + /// # Arguments + /// + /// * `email` - The email address to invite + /// * `inviter_id` - Optional user ID of the person creating the invitation + /// * `metadata` - Optional application-specific data (roles, permissions, etc.) + pub async fn create_invitation( + &self, + email: &str, + inviter_id: Option, + metadata: Option, + ) -> Result<(Invitation, Option), Error> { + self.create_invitation_with_expiration(email, inviter_id, metadata, self.config.expires_in) + .await + } + + /// Create a new invitation with custom expiration. + pub async fn create_invitation_with_expiration( + &self, + email: &str, + inviter_id: Option, + metadata: Option, + expires_in: Duration, + ) -> Result<(Invitation, Option), Error> { + // Validate email format + validate_email(email)?; + + // Check for existing pending invitations + let pending_count = self + .invitation_repository + .count_pending_by_email(email) + .await?; + + if pending_count >= self.config.max_pending_per_email { + return Err(Error::Validation( + crate::error::ValidationError::InvalidField(format!( + "Too many pending invitations for email: {email}" + )), + )); + } + + // Check if user already exists + let existing_user = self.user_repository.find_by_email(email).await?; + + // Create provisional user if configured and user doesn't exist + let provisional_user = if self.config.create_provisional_user && existing_user.is_none() { + let mut builder = NewUser::builder() + .email(email.to_string()) + .status(UserStatus::Provisional); + + // Only set invited_by if we have an actual inviter + if let Some(ref inviter) = inviter_id { + builder = builder.invited_by(inviter.clone()); + } + + Some(self.user_repository.create(builder.build()?).await?) + } else { + None + }; + + // Generate secure token + let token = generate_secure_token(); + let token_hash = hash_token(&token); + let now = Utc::now(); + let expires_at = now + expires_in; + + // Create invitation + let invitation = Invitation::new( + InvitationId::new_random(), + email.to_string(), + token, + token_hash, + inviter_id, + metadata, + expires_at, + now, + now, + ); + + let stored_invitation = self.invitation_repository.create(&invitation).await?; + + // Return invitation with plaintext token + Ok(( + Invitation::new( + stored_invitation.id, + stored_invitation.email, + invitation.token().unwrap().to_string(), + stored_invitation.token_hash, + stored_invitation.inviter_id, + stored_invitation.metadata, + stored_invitation.expires_at, + stored_invitation.created_at, + stored_invitation.updated_at, + ), + provisional_user, + )) + } + + /// Get an invitation by its token. + /// + /// This validates the token and returns the invitation if found and valid. + /// Does not consume the token. + pub async fn get_invitation_by_token(&self, token: &str) -> Result, Error> { + let token_hash = hash_token(token); + let invitation = self + .invitation_repository + .find_by_token_hash(&token_hash) + .await?; + + // Token hash lookup is deterministic (SHA256), so if we found a match + // we just need to check validity (not expired, still pending) + match invitation { + Some(inv) if inv.is_valid() => Ok(Some(inv)), + _ => Ok(None), + } + } + + /// Verify an invitation token without consuming it. + /// + /// Returns true if the token is valid and the invitation can be accepted. + pub async fn verify_token(&self, token: &str) -> Result { + let invitation = self.get_invitation_by_token(token).await?; + Ok(invitation.map(|inv| inv.can_accept()).unwrap_or(false)) + } + + /// Accept an invitation. + /// + /// This will: + /// 1. Verify the token is valid + /// 2. Mark the invitation as accepted + /// 3. If a provisional user exists, activate them + /// 4. Return the user associated with this invitation + /// + /// # Arguments + /// + /// * `token` - The invitation token + /// * `user_id` - The user ID accepting the invitation (may be a new user or existing) + pub async fn accept_invitation( + &self, + token: &str, + user_id: &UserId, + ) -> Result<(Invitation, User), Error> { + let token_hash = hash_token(token); + let invitation = self + .invitation_repository + .find_by_token_hash(&token_hash) + .await? + .ok_or(Error::Auth(AuthError::InvalidCredentials))?; + + // Verify token and check if invitation can be accepted + if !invitation.verify(token) { + return Err(Error::Auth(AuthError::InvalidCredentials)); + } + + if !invitation.can_accept() { + if invitation.is_expired() { + return Err(Error::Validation( + crate::error::ValidationError::InvalidField( + "Invitation has expired".to_string(), + ), + )); + } + return Err(Error::Validation( + crate::error::ValidationError::InvalidField(format!( + "Invitation cannot be accepted (status: {})", + invitation.status + )), + )); + } + + // Mark invitation as accepted + let accepted_invitation = self + .invitation_repository + .accept(&invitation.id, user_id) + .await?; + + // Get or activate the user + let user = self + .user_repository + .find_by_id(user_id) + .await? + .ok_or(Error::Auth(AuthError::UserNotFound))?; + + // If user is provisional, activate them + let user = if user.is_provisional() { + let mut activated_user = user; + activated_user.status = UserStatus::Active; + self.user_repository.update(&activated_user).await? + } else { + user + }; + + Ok((accepted_invitation, user)) + } + + /// Revoke an invitation. + /// + /// This marks the invitation as revoked, preventing it from being accepted. + pub async fn revoke_invitation(&self, id: &InvitationId) -> Result { + self.invitation_repository.revoke(id).await + } + + /// Get an invitation by its ID. + pub async fn get_invitation(&self, id: &InvitationId) -> Result, Error> { + self.invitation_repository.find_by_id(id).await + } + + /// List all pending invitations for an email address. + pub async fn list_pending_invitations(&self, email: &str) -> Result, Error> { + self.invitation_repository + .find_pending_by_email(email) + .await + } + + /// List all invitations sent by a user. + pub async fn list_invitations_by_inviter( + &self, + inviter_id: &UserId, + ) -> Result, Error> { + self.invitation_repository.find_by_inviter(inviter_id).await + } + + /// Clean up expired invitations. + /// + /// Returns the number of invitations that were expired. + pub async fn cleanup_expired(&self) -> Result { + self.invitation_repository.cleanup_expired().await + } + + /// Accept pending invitations for a user after signup. + /// + /// This is called after a user completes signup to automatically accept + /// any pending invitations for their email address. This enables the + /// flow where a user can be invited and then sign up with any auth method. + /// + /// Returns the list of accepted invitations. + pub async fn accept_pending_invitations_for_user( + &self, + user: &User, + ) -> Result, Error> { + let pending = self + .invitation_repository + .find_pending_by_email(&user.email) + .await?; + + let mut accepted = Vec::new(); + + for invitation in pending { + if invitation.can_accept() { + let accepted_inv = self + .invitation_repository + .accept(&invitation.id, &user.id) + .await?; + accepted.push(accepted_inv); + } + } + + Ok(accepted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InvitationStatus; + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::Mutex; + + // Mock InvitationRepository + struct MockInvitationRepository { + invitations: Mutex>, + } + + impl MockInvitationRepository { + fn new() -> Self { + Self { + invitations: Mutex::new(HashMap::new()), + } + } + } + + #[async_trait] + impl InvitationRepository for MockInvitationRepository { + async fn create(&self, invitation: &Invitation) -> Result { + let mut invitations = self.invitations.lock().unwrap(); + invitations.insert(invitation.id.as_str().to_string(), invitation.clone()); + Ok(invitation.clone()) + } + + async fn find_by_id(&self, id: &InvitationId) -> Result, Error> { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations.get(id.as_str()).cloned()) + } + + async fn find_by_token_hash(&self, token_hash: &str) -> Result, Error> { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations + .values() + .find(|inv| inv.token_hash == token_hash) + .cloned()) + } + + async fn find_by_email(&self, email: &str) -> Result, Error> { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations + .values() + .filter(|inv| inv.email == email) + .cloned() + .collect()) + } + + async fn find_pending_by_email(&self, email: &str) -> Result, Error> { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations + .values() + .filter(|inv| inv.email == email && inv.status == InvitationStatus::Pending) + .cloned() + .collect()) + } + + async fn find_by_inviter(&self, inviter_id: &UserId) -> Result, Error> { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations + .values() + .filter(|inv| inv.inviter_id.as_ref() == Some(inviter_id)) + .cloned() + .collect()) + } + + async fn update_status( + &self, + id: &InvitationId, + status: InvitationStatus, + ) -> Result { + let mut invitations = self.invitations.lock().unwrap(); + if let Some(inv) = invitations.get_mut(id.as_str()) { + let updated = Invitation::from_storage( + inv.id.clone(), + inv.email.clone(), + inv.token_hash.clone(), + inv.inviter_id.clone(), + status, + inv.metadata.clone(), + inv.expires_at, + inv.accepted_at, + inv.accepted_by.clone(), + inv.revoked_at, + inv.created_at, + Utc::now(), + ); + *inv = updated.clone(); + Ok(updated) + } else { + Err(Error::Storage(crate::error::StorageError::NotFound)) + } + } + + async fn accept( + &self, + id: &InvitationId, + accepted_by: &UserId, + ) -> Result { + let mut invitations = self.invitations.lock().unwrap(); + if let Some(inv) = invitations.get_mut(id.as_str()) { + let now = Utc::now(); + let updated = Invitation::from_storage( + inv.id.clone(), + inv.email.clone(), + inv.token_hash.clone(), + inv.inviter_id.clone(), + InvitationStatus::Accepted, + inv.metadata.clone(), + inv.expires_at, + Some(now), + Some(accepted_by.clone()), + None, + inv.created_at, + now, + ); + *inv = updated.clone(); + Ok(updated) + } else { + Err(Error::Storage(crate::error::StorageError::NotFound)) + } + } + + async fn revoke(&self, id: &InvitationId) -> Result { + let mut invitations = self.invitations.lock().unwrap(); + if let Some(inv) = invitations.get_mut(id.as_str()) { + let now = Utc::now(); + let updated = Invitation::from_storage( + inv.id.clone(), + inv.email.clone(), + inv.token_hash.clone(), + inv.inviter_id.clone(), + InvitationStatus::Revoked, + inv.metadata.clone(), + inv.expires_at, + None, + None, + Some(now), + inv.created_at, + now, + ); + *inv = updated.clone(); + Ok(updated) + } else { + Err(Error::Storage(crate::error::StorageError::NotFound)) + } + } + + async fn delete(&self, id: &InvitationId) -> Result<(), Error> { + let mut invitations = self.invitations.lock().unwrap(); + invitations.remove(id.as_str()); + Ok(()) + } + + async fn cleanup_expired(&self) -> Result { + let mut invitations = self.invitations.lock().unwrap(); + let now = Utc::now(); + let expired_ids: Vec<_> = invitations + .iter() + .filter(|(_, inv)| inv.status == InvitationStatus::Pending && inv.expires_at < now) + .map(|(id, _)| id.clone()) + .collect(); + + let count = expired_ids.len() as u64; + for id in expired_ids { + if let Some(inv) = invitations.get_mut(&id) { + let updated = Invitation::from_storage( + inv.id.clone(), + inv.email.clone(), + inv.token_hash.clone(), + inv.inviter_id.clone(), + InvitationStatus::Expired, + inv.metadata.clone(), + inv.expires_at, + None, + None, + None, + inv.created_at, + now, + ); + *inv = updated; + } + } + Ok(count) + } + + async fn count_pending_by_email(&self, email: &str) -> Result { + let invitations = self.invitations.lock().unwrap(); + Ok(invitations + .values() + .filter(|inv| inv.email == email && inv.status == InvitationStatus::Pending) + .count() as u64) + } + } + + // Mock UserRepository + struct MockUserRepository { + users: Mutex>, + } + + impl MockUserRepository { + fn new() -> Self { + Self { + users: Mutex::new(HashMap::new()), + } + } + } + + #[async_trait] + impl UserRepository for MockUserRepository { + async fn create(&self, user: NewUser) -> Result { + let now = Utc::now(); + let created = User::builder() + .id(user.id.clone()) + .email(user.email.clone()) + .name(user.name) + .email_verified_at(user.email_verified_at) + .status(user.status) + .invited_by(user.invited_by) + .created_at(now) + .updated_at(now) + .build()?; + + let mut users = self.users.lock().unwrap(); + users.insert(user.id.as_str().to_string(), created.clone()); + Ok(created) + } + + async fn find_by_id(&self, id: &UserId) -> Result, Error> { + let users = self.users.lock().unwrap(); + Ok(users.get(id.as_str()).cloned()) + } + + async fn find_by_email(&self, email: &str) -> Result, Error> { + let users = self.users.lock().unwrap(); + Ok(users.values().find(|u| u.email == email).cloned()) + } + + async fn find_or_create_by_email(&self, email: &str) -> Result { + if let Some(user) = self.find_by_email(email).await? { + Ok(user) + } else { + self.create(NewUser::new(email.to_string())).await + } + } + + async fn update(&self, user: &User) -> Result { + let mut users = self.users.lock().unwrap(); + users.insert(user.id.as_str().to_string(), user.clone()); + Ok(user.clone()) + } + + async fn delete(&self, id: &UserId) -> Result<(), Error> { + let mut users = self.users.lock().unwrap(); + users.remove(id.as_str()); + Ok(()) + } + + async fn mark_email_verified(&self, user_id: &UserId) -> Result<(), Error> { + let mut users = self.users.lock().unwrap(); + if let Some(user) = users.get_mut(user_id.as_str()) { + user.email_verified_at = Some(Utc::now()); + } + Ok(()) + } + } + + #[tokio::test] + async fn test_create_invitation() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo.clone(), user_repo.clone()); + + let (invitation, user) = service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + assert_eq!(invitation.email, "test@example.com"); + assert!(invitation.token().is_some()); + assert_eq!(invitation.status, InvitationStatus::Pending); + assert!(user.is_some()); // Provisional user created + + let user = user.unwrap(); + assert_eq!(user.status, UserStatus::Provisional); + } + + #[tokio::test] + async fn test_verify_token() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo, user_repo); + + let (invitation, _) = service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + let token = invitation.token().unwrap().to_string(); + + // Valid token + assert!(service.verify_token(&token).await.unwrap()); + + // Invalid token + assert!(!service.verify_token("invalid").await.unwrap()); + } + + #[tokio::test] + async fn test_accept_invitation() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo, user_repo); + + let (invitation, user) = service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + let token = invitation.token().unwrap().to_string(); + let provisional_user = user.unwrap(); + + let (accepted_inv, activated_user) = service + .accept_invitation(&token, &provisional_user.id) + .await + .unwrap(); + + assert_eq!(accepted_inv.status, InvitationStatus::Accepted); + assert!(accepted_inv.accepted_at.is_some()); + assert_eq!(activated_user.status, UserStatus::Active); + } + + #[tokio::test] + async fn test_revoke_invitation() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo, user_repo); + + let (invitation, _) = service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + let revoked = service.revoke_invitation(&invitation.id).await.unwrap(); + + assert_eq!(revoked.status, InvitationStatus::Revoked); + assert!(revoked.revoked_at.is_some()); + } + + #[tokio::test] + async fn test_max_pending_invitations() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let config = InvitationConfig { + max_pending_per_email: 2, + create_provisional_user: false, // Disable to avoid unique constraint on email + ..Default::default() + }; + let service = InvitationService::with_config(invitation_repo, user_repo, config); + + // First two should succeed + service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + // Third should fail + let result = service + .create_invitation("test@example.com", None, None) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_create_invitation_validates_email() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo, user_repo); + + // Invalid email should fail + let result = service.create_invitation("invalid-email", None, None).await; + assert!(result.is_err()); + + // Empty email should fail + let result = service.create_invitation("", None, None).await; + assert!(result.is_err()); + + // Valid email should succeed + let result = service + .create_invitation("valid@example.com", None, None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_create_invitation_without_inviter_has_no_invited_by() { + let invitation_repo = Arc::new(MockInvitationRepository::new()); + let user_repo = Arc::new(MockUserRepository::new()); + let service = InvitationService::new(invitation_repo, user_repo); + + // Create invitation without inviter + let (_, provisional_user) = service + .create_invitation("test@example.com", None, None) + .await + .unwrap(); + + // Provisional user should have no invited_by + let user = provisional_user.unwrap(); + assert!(user.invited_by.is_none()); + } +} diff --git a/torii-core/src/services/magic_link.rs b/torii-core/src/services/magic_link.rs index 3d284da..6d55a43 100644 --- a/torii-core/src/services/magic_link.rs +++ b/torii-core/src/services/magic_link.rs @@ -101,6 +101,8 @@ mod tests { email: user.email, name: user.name, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: user.created_at, updated_at: user.updated_at, diff --git a/torii-core/src/services/mailer.rs b/torii-core/src/services/mailer.rs index 6317463..84c2979 100644 --- a/torii-core/src/services/mailer.rs +++ b/torii-core/src/services/mailer.rs @@ -37,6 +37,14 @@ mod mailer_impl { verification_link: &str, user_name: Option<&str>, ) -> Result<(), Error>; + + async fn send_invitation_email( + &self, + to: &str, + invitation_link: &str, + inviter_name: Option<&str>, + expires_in_days: i64, + ) -> Result<(), Error>; } pub struct ToriiMailerService { @@ -196,6 +204,34 @@ mod mailer_impl { Ok(()) } + + async fn send_invitation_email( + &self, + to: &str, + invitation_link: &str, + inviter_name: Option<&str>, + expires_in_days: i64, + ) -> Result<(), Error> { + let context = self.create_context(inviter_name, Some(to)); + + let email = InvitationEmail::build( + &self.engine, + &self.config.get_from_address(), + to, + invitation_link, + inviter_name, + expires_in_days, + context, + ) + .await + .map_err(|e| Error::Storage(crate::error::StorageError::Connection(e.to_string())))?; + + self.transport.send_email(email).await.map_err(|e| { + Error::Storage(crate::error::StorageError::Connection(e.to_string())) + })?; + + Ok(()) + } } #[cfg(test)] diff --git a/torii-core/src/services/mod.rs b/torii-core/src/services/mod.rs index 4dd2892..81192c2 100644 --- a/torii-core/src/services/mod.rs +++ b/torii-core/src/services/mod.rs @@ -5,6 +5,7 @@ pub mod brute_force; pub mod email_verification; +pub mod invitation; pub mod magic_link; pub mod mailer; pub mod oauth; @@ -16,6 +17,7 @@ pub mod user; pub use brute_force::BruteForceProtectionService; pub use email_verification::EmailVerificationService; +pub use invitation::{InvitationConfig, InvitationService}; pub use magic_link::MagicLinkService; pub use oauth::OAuthService; pub use passkey::PasskeyService; diff --git a/torii-core/src/services/oauth.rs b/torii-core/src/services/oauth.rs index 25b7aac..841d212 100644 --- a/torii-core/src/services/oauth.rs +++ b/torii-core/src/services/oauth.rs @@ -167,6 +167,8 @@ mod tests { email: user.email, name: user.name, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: user.created_at, updated_at: user.updated_at, @@ -308,6 +310,8 @@ mod tests { email: "test@example.com".to_string(), name: None, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: Utc::now(), updated_at: Utc::now(), diff --git a/torii-core/src/services/passkey.rs b/torii-core/src/services/passkey.rs index adb905b..8d401f0 100644 --- a/torii-core/src/services/passkey.rs +++ b/torii-core/src/services/passkey.rs @@ -119,6 +119,8 @@ mod tests { email: user.email, name: user.name, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: user.created_at, updated_at: user.updated_at, diff --git a/torii-core/src/services/password.rs b/torii-core/src/services/password.rs index 4916099..4d81017 100644 --- a/torii-core/src/services/password.rs +++ b/torii-core/src/services/password.rs @@ -186,6 +186,8 @@ mod tests { email: user.email, name: user.name, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: user.created_at, updated_at: user.updated_at, diff --git a/torii-core/src/services/password_reset.rs b/torii-core/src/services/password_reset.rs index fa8c9bc..b05f9c1 100644 --- a/torii-core/src/services/password_reset.rs +++ b/torii-core/src/services/password_reset.rs @@ -174,6 +174,8 @@ mod tests { email: user.email, name: user.name, email_verified_at: None, + status: crate::UserStatus::Active, + invited_by: None, locked_at: None, created_at: user.created_at, updated_at: user.updated_at, diff --git a/torii-core/src/storage.rs b/torii-core/src/storage.rs index a641136..e892c4c 100644 --- a/torii-core/src/storage.rs +++ b/torii-core/src/storage.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use secrecy::{ExposeSecret, SecretString}; -use crate::{Error, UserId, error::utilities::RequiredFieldExt}; +use crate::{Error, UserId, error::utilities::RequiredFieldExt, user::UserStatus}; // ============================================================================ // Brute Force Protection Types @@ -157,6 +157,8 @@ pub struct NewUser { pub email: String, pub name: Option, pub email_verified_at: Option>, + pub status: UserStatus, + pub invited_by: Option, } impl NewUser { @@ -186,6 +188,8 @@ pub struct NewUserBuilder { email: Option, name: Option, email_verified_at: Option>, + status: Option, + invited_by: Option, } impl NewUserBuilder { @@ -209,12 +213,24 @@ impl NewUserBuilder { self } + pub fn status(mut self, status: UserStatus) -> Self { + self.status = Some(status); + self + } + + pub fn invited_by(mut self, invited_by: UserId) -> Self { + self.invited_by = Some(invited_by); + self + } + pub fn build(self) -> Result { Ok(NewUser { id: self.id.unwrap_or_default(), email: self.email.require_field("Email")?, name: self.name, email_verified_at: self.email_verified_at, + status: self.status.unwrap_or_default(), + invited_by: self.invited_by, }) } } diff --git a/torii-core/src/user.rs b/torii-core/src/user.rs index a6404bc..6441b70 100644 --- a/torii-core/src/user.rs +++ b/torii-core/src/user.rs @@ -10,6 +10,7 @@ //! | `name` | `String` | The name of the user. | //! | `email` | `String` | The email of the user. | //! | `email_verified_at` | `Option` | The timestamp when the user's email was verified. | +//! | `status` | `UserStatus` | The status of the user (provisional or active). | //! | `created_at` | `DateTime` | The timestamp when the user was created. | //! | `updated_at` | `DateTime` | The timestamp when the user was last updated. | use std::str::FromStr; @@ -24,6 +25,62 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +/// The status of a user account. +/// +/// This enum represents the lifecycle state of a user: +/// - `Provisional`: The user was created via an invitation but has not yet completed signup +/// - `Active`: The user has completed signup and can authenticate +/// +/// Provisional users are created when someone is invited to the system. They have a user ID +/// that can be used for references (e.g., sharing resources), but cannot authenticate until +/// they complete the signup process and become active. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UserStatus { + /// User was invited but has not completed signup + Provisional, + /// User has completed signup and can authenticate + #[default] + Active, +} + +impl UserStatus { + /// Get the string representation for storage + pub fn as_str(&self) -> &'static str { + match self { + UserStatus::Provisional => "provisional", + UserStatus::Active => "active", + } + } + + /// Check if the user is provisional (invited but not yet signed up) + pub fn is_provisional(&self) -> bool { + matches!(self, UserStatus::Provisional) + } + + /// Check if the user is active (can authenticate) + pub fn is_active(&self) -> bool { + matches!(self, UserStatus::Active) + } +} + +impl FromStr for UserStatus { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "provisional" => Ok(UserStatus::Provisional), + "active" => Ok(UserStatus::Active), + _ => Err(ValidationError::InvalidField(format!("Invalid user status: {s}")).into()), + } + } +} + +impl std::fmt::Display for UserStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + /// A unique, stable identifier for a specific user /// This value should be treated as opaque, and should not be used as a UUID even if it may look like one #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -163,6 +220,18 @@ pub struct User { // The email verified at timestamp. If the user has not verified their email, this will be None. pub email_verified_at: Option>, + /// The status of the user account. + /// + /// - `Provisional`: User was created via invitation but hasn't completed signup + /// - `Active`: User has completed signup and can authenticate + pub status: UserStatus, + + /// The user ID of whoever invited this user, if applicable. + /// + /// This is set when a user is created via an invitation and can be used + /// for referral tracking or permission inheritance. + pub invited_by: Option, + /// When the account was locked due to brute force protection. /// /// This field is set when an account becomes locked after too many failed @@ -196,6 +265,16 @@ impl User { pub fn is_locked(&self) -> bool { self.locked_at.is_some() } + + /// Check if the user is provisional (invited but hasn't completed signup). + pub fn is_provisional(&self) -> bool { + self.status.is_provisional() + } + + /// Check if the user is active (can authenticate). + pub fn is_active(&self) -> bool { + self.status.is_active() + } } #[derive(Default)] @@ -204,6 +283,8 @@ pub struct UserBuilder { name: Option, email: Option, email_verified_at: Option>, + status: Option, + invited_by: Option, locked_at: Option>, created_at: Option>, updated_at: Option>, @@ -230,6 +311,16 @@ impl UserBuilder { self } + pub fn status(mut self, status: UserStatus) -> Self { + self.status = Some(status); + self + } + + pub fn invited_by(mut self, invited_by: Option) -> Self { + self.invited_by = invited_by; + self + } + pub fn locked_at(mut self, locked_at: Option>) -> Self { self.locked_at = locked_at; self @@ -254,6 +345,8 @@ impl UserBuilder { "Email is required".to_string(), ))?, email_verified_at: self.email_verified_at, + status: self.status.unwrap_or_default(), + invited_by: self.invited_by, locked_at: self.locked_at, created_at: self.created_at.unwrap_or(now), updated_at: self.updated_at.unwrap_or(now), diff --git a/torii-mailer/src/email_types.rs b/torii-mailer/src/email_types.rs index 20e8035..01da11c 100644 --- a/torii-mailer/src/email_types.rs +++ b/torii-mailer/src/email_types.rs @@ -147,6 +147,59 @@ impl EmailVerificationEmail { } } +pub struct InvitationEmail; + +impl InvitationEmail { + /// Build an invitation email. + /// + /// # Arguments + /// + /// * `engine` - The template engine to use + /// * `from` - The sender email address + /// * `to` - The recipient email address (the invitee) + /// * `invitation_link` - The link to accept the invitation + /// * `inviter_name` - Optional name of the person who sent the invitation + /// * `expires_in_days` - Number of days until the invitation expires + /// * `context` - Template context with app information + pub async fn build( + engine: &T, + from: &str, + to: &str, + invitation_link: &str, + inviter_name: Option<&str>, + expires_in_days: i64, + context: TemplateContext, + ) -> Result { + let mut template_data = TemplateData::new() + .insert("context", &context)? + .insert("invitation_link", invitation_link)? + .insert("expires_in_days", expires_in_days)?; + + if let Some(inviter) = inviter_name { + template_data = template_data.insert("inviter_name", inviter)?; + } + + let html_body = engine + .render_html("invitation", template_data.clone()) + .await?; + let text_body = engine.render_text("invitation", template_data).await?; + + let subject = if let Some(inviter) = inviter_name { + format!("{} has invited you to join {}", inviter, context.app_name) + } else { + format!("You've been invited to join {}", context.app_name) + }; + + Email::builder() + .from(from) + .to(to) + .subject(subject) + .html_body(html_body) + .text_body(text_body) + .build() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/torii-mailer/src/lib.rs b/torii-mailer/src/lib.rs index b8589e3..28a67e6 100644 --- a/torii-mailer/src/lib.rs +++ b/torii-mailer/src/lib.rs @@ -9,7 +9,8 @@ pub mod transports; pub use config::MailerConfig; pub use email::{Email, EmailBuilder}; pub use email_types::{ - EmailVerificationEmail, MagicLinkEmail, PasswordChangedEmail, PasswordResetEmail, WelcomeEmail, + EmailVerificationEmail, InvitationEmail, MagicLinkEmail, PasswordChangedEmail, + PasswordResetEmail, WelcomeEmail, }; pub use error::MailerError; pub use mailer::Mailer; @@ -19,7 +20,7 @@ pub use transports::{FileTransport, SendmailTransport, SmtpTransport}; pub mod prelude { pub use crate::{ AskamaTemplateEngine, Email, EmailBuilder, EmailVerificationEmail, FileTransport, - MagicLinkEmail, Mailer, MailerConfig, MailerError, PasswordChangedEmail, + InvitationEmail, MagicLinkEmail, Mailer, MailerConfig, MailerError, PasswordChangedEmail, PasswordResetEmail, SendmailTransport, SmtpTransport, TemplateContext, TemplateEngine, WelcomeEmail, }; diff --git a/torii-mailer/src/templates/auth_templates.rs b/torii-mailer/src/templates/auth_templates.rs index 03e1c77..7dc99c8 100644 --- a/torii-mailer/src/templates/auth_templates.rs +++ b/torii-mailer/src/templates/auth_templates.rs @@ -401,3 +401,102 @@ impl EmailVerificationTemplate { }) } } + +#[derive(Template)] +#[template( + source = r#" + + + + + + You've Been Invited - {{ app_name }} + + + +
+
+

{{ app_name }}

+
+ +

You've Been Invited!

+ +

Hello,

+ + {% if let Some(inviter) = inviter_name %} +
+ {{ inviter }} has invited you to join {{ app_name }}. +
+ {% else %} +

You've been invited to join {{ app_name }}.

+ {% endif %} + +

Click the button below to accept the invitation and create your account:

+ + + +

Or copy and paste this URL into your browser:

+

{{ invitation_link }}

+ +

This invitation will expire in {{ expires_in_days }} days for security reasons.

+ +

If you didn't expect this invitation, you can safely ignore this email.

+ + +
+ + +"#, + ext = "html" +)] +pub struct InvitationTemplate { + pub app_name: String, + pub app_url: String, + pub inviter_name: Option, + pub invitation_link: String, + pub expires_in_days: i64, +} + +impl InvitationTemplate { + pub fn from_data(data: TemplateData) -> Result { + let context: TemplateContext = data + .get("context") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let invitation_link = data + .get("invitation_link") + .and_then(|v| v.as_str()) + .ok_or_else(|| MailerError::Builder("invitation_link is required".to_string()))? + .to_string(); + + let inviter_name = data + .get("inviter_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let expires_in_days = data + .get("expires_in_days") + .and_then(|v| v.as_i64()) + .unwrap_or(7); + + Ok(Self { + app_name: context.app_name, + app_url: context.app_url, + inviter_name, + invitation_link, + expires_in_days, + }) + } +} diff --git a/torii-mailer/src/templates/engine.rs b/torii-mailer/src/templates/engine.rs index eb00993..200bd7f 100644 --- a/torii-mailer/src/templates/engine.rs +++ b/torii-mailer/src/templates/engine.rs @@ -76,6 +76,10 @@ impl TemplateEngine for AskamaTemplateEngine { let template = crate::templates::EmailVerificationTemplate::from_data(data)?; Ok(template.render()?) } + "invitation" => { + let template = crate::templates::InvitationTemplate::from_data(data)?; + Ok(template.render()?) + } _ => Err(MailerError::Template(askama::Error::Fmt(std::fmt::Error))), } } diff --git a/torii-mailer/src/templates/mod.rs b/torii-mailer/src/templates/mod.rs index 9f67640..272490c 100644 --- a/torii-mailer/src/templates/mod.rs +++ b/torii-mailer/src/templates/mod.rs @@ -2,8 +2,8 @@ mod auth_templates; mod engine; pub use auth_templates::{ - EmailVerificationTemplate, MagicLinkTemplate, PasswordChangedTemplate, PasswordResetTemplate, - TemplateContext, WelcomeTemplate, + EmailVerificationTemplate, InvitationTemplate, MagicLinkTemplate, PasswordChangedTemplate, + PasswordResetTemplate, TemplateContext, WelcomeTemplate, }; pub use engine::{AskamaTemplateEngine, TemplateEngine}; diff --git a/torii-storage-postgres/Cargo.toml b/torii-storage-postgres/Cargo.toml index a742635..4fba029 100644 --- a/torii-storage-postgres/Cargo.toml +++ b/torii-storage-postgres/Cargo.toml @@ -13,7 +13,8 @@ async-trait.workspace = true base64.workspace = true chrono.workspace = true rand.workspace = true -sqlx = { workspace = true, features = ["postgres", "uuid"] } +serde_json.workspace = true +sqlx = { workspace = true, features = ["postgres", "uuid", "json"] } tracing.workspace = true uuid.workspace = true diff --git a/torii-storage-postgres/src/lib.rs b/torii-storage-postgres/src/lib.rs index edacf12..1d847c4 100644 --- a/torii-storage-postgres/src/lib.rs +++ b/torii-storage-postgres/src/lib.rs @@ -63,8 +63,10 @@ use chrono::DateTime; use chrono::Utc; use migrations::AddLockedAtToUsers; use migrations::AddPasskeyMetadata; +use migrations::AddUserStatusAndInvitedBy; use migrations::CreateFailedLoginAttemptsTable; use migrations::CreateIndexes; +use migrations::CreateInvitationsTable; use migrations::CreateOAuthAccountsTable; use migrations::CreateOAuthStateTable; use migrations::CreatePasskeyChallengesTable; @@ -117,6 +119,8 @@ impl PostgresStorage { Box::new(CreateOAuthStateTable), Box::new(CreateSecureTokensTable), Box::new(AddPasskeyMetadata), + Box::new(AddUserStatusAndInvitedBy), + Box::new(CreateInvitationsTable), ]; manager.up(&migrations).await.map_err(|e| { tracing::error!(error = %e, "Failed to run migrations"); @@ -138,6 +142,8 @@ pub struct PostgresUser { pub email: String, pub name: Option, pub email_verified_at: Option>, + pub status: String, + pub invited_by: Option, pub locked_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, @@ -150,6 +156,8 @@ impl From for User { .email(user.email) .name(user.name) .email_verified_at(user.email_verified_at) + .status(user.status.parse().unwrap_or_default()) + .invited_by(user.invited_by.map(|id| UserId::new(&id))) .locked_at(user.locked_at) .created_at(user.created_at) .updated_at(user.updated_at) @@ -165,6 +173,8 @@ impl From for PostgresUser { email: user.email, name: user.name, email_verified_at: user.email_verified_at, + status: user.status.as_str().to_string(), + invited_by: user.invited_by.map(|id| id.into_inner()), locked_at: user.locked_at, created_at: user.created_at, updated_at: user.updated_at, diff --git a/torii-storage-postgres/src/migrations/mod.rs b/torii-storage-postgres/src/migrations/mod.rs index 063f925..7a77bdd 100644 --- a/torii-storage-postgres/src/migrations/mod.rs +++ b/torii-storage-postgres/src/migrations/mod.rs @@ -796,6 +796,167 @@ impl Migration for AddPasskeyMetadata { } } +/// Migration to add status and invited_by columns to users table for invitation support. +pub struct AddUserStatusAndInvitedBy; + +#[async_trait] +impl Migration for AddUserStatusAndInvitedBy { + fn version(&self) -> i64 { + 13 + } + + fn name(&self) -> &str { + "AddUserStatusAndInvitedBy" + } + + async fn up<'a>( + &'a self, + conn: &'a mut ::Connection, + ) -> Result<(), MigrationError> { + // Add status column with default 'active' for existing users + sqlx::query( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'", + ) + .execute(&mut *conn) + .await?; + + // Add invited_by column (nullable FK to users) + sqlx::query("ALTER TABLE users ADD COLUMN IF NOT EXISTS invited_by TEXT REFERENCES users(id) ON DELETE SET NULL") + .execute(&mut *conn) + .await?; + + // Index for finding users by status + sqlx::query("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)") + .execute(&mut *conn) + .await?; + + // Index for finding users invited by a specific user + sqlx::query("CREATE INDEX IF NOT EXISTS idx_users_invited_by ON users(invited_by)") + .execute(&mut *conn) + .await?; + + Ok(()) + } + + async fn down<'a>( + &'a self, + conn: &'a mut ::Connection, + ) -> Result<(), MigrationError> { + sqlx::query("DROP INDEX IF EXISTS idx_users_invited_by") + .execute(&mut *conn) + .await?; + sqlx::query("DROP INDEX IF EXISTS idx_users_status") + .execute(&mut *conn) + .await?; + sqlx::query("ALTER TABLE users DROP COLUMN IF EXISTS invited_by") + .execute(&mut *conn) + .await?; + sqlx::query("ALTER TABLE users DROP COLUMN IF EXISTS status") + .execute(&mut *conn) + .await?; + Ok(()) + } +} + +/// Migration to create the invitations table. +pub struct CreateInvitationsTable; + +#[async_trait] +impl Migration for CreateInvitationsTable { + fn version(&self) -> i64 { + 14 + } + + fn name(&self) -> &str { + "CreateInvitationsTable" + } + + async fn up<'a>( + &'a self, + conn: &'a mut ::Connection, + ) -> Result<(), MigrationError> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + inviter_id TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + metadata JSONB, + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + accepted_by TEXT REFERENCES users(id) ON DELETE SET NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )"#, + ) + .execute(&mut *conn) + .await?; + + // Index for token lookup + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_invitations_token_hash ON invitations(token_hash)", + ) + .execute(&mut *conn) + .await?; + + // Index for finding invitations by email + sqlx::query("CREATE INDEX IF NOT EXISTS idx_invitations_email ON invitations(email)") + .execute(&mut *conn) + .await?; + + // Index for finding pending invitations by email (most common query) + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_invitations_email_status ON invitations(email, status)", + ) + .execute(&mut *conn) + .await?; + + // Index for finding invitations by inviter + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_invitations_inviter_id ON invitations(inviter_id)", + ) + .execute(&mut *conn) + .await?; + + // Index for cleanup of expired invitations + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_invitations_expires_at ON invitations(expires_at)", + ) + .execute(&mut *conn) + .await?; + + Ok(()) + } + + async fn down<'a>( + &'a self, + conn: &'a mut ::Connection, + ) -> Result<(), MigrationError> { + sqlx::query("DROP INDEX IF EXISTS idx_invitations_expires_at") + .execute(&mut *conn) + .await?; + sqlx::query("DROP INDEX IF EXISTS idx_invitations_inviter_id") + .execute(&mut *conn) + .await?; + sqlx::query("DROP INDEX IF EXISTS idx_invitations_email_status") + .execute(&mut *conn) + .await?; + sqlx::query("DROP INDEX IF EXISTS idx_invitations_email") + .execute(&mut *conn) + .await?; + sqlx::query("DROP INDEX IF EXISTS idx_invitations_token_hash") + .execute(&mut *conn) + .await?; + sqlx::query("DROP TABLE IF EXISTS invitations") + .execute(&mut *conn) + .await?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/torii-storage-postgres/src/repositories/invitation.rs b/torii-storage-postgres/src/repositories/invitation.rs new file mode 100644 index 0000000..8457bcd --- /dev/null +++ b/torii-storage-postgres/src/repositories/invitation.rs @@ -0,0 +1,306 @@ +//! PostgreSQL implementation of the invitation repository. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use torii_core::{ + Error, Invitation, InvitationId, InvitationStatus, UserId, error::StorageError, + repositories::InvitationRepository, +}; + +/// PostgreSQL row type for invitations. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PostgresInvitation { + pub id: String, + pub email: String, + pub token_hash: String, + pub inviter_id: Option, + pub status: String, + pub metadata: Option, + pub expires_at: DateTime, + pub accepted_at: Option>, + pub accepted_by: Option, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for Invitation { + fn from(inv: PostgresInvitation) -> Self { + Invitation::from_storage( + InvitationId::new(&inv.id), + inv.email, + inv.token_hash, + inv.inviter_id.map(|id| UserId::new(&id)), + inv.status.parse().unwrap_or(InvitationStatus::Pending), + inv.metadata, + inv.expires_at, + inv.accepted_at, + inv.accepted_by.map(|id| UserId::new(&id)), + inv.revoked_at, + inv.created_at, + inv.updated_at, + ) + } +} + +/// PostgreSQL repository for invitation data. +pub struct PostgresInvitationRepository { + pool: PgPool, +} + +impl PostgresInvitationRepository { + /// Create a new PostgreSQL invitation repository. + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl InvitationRepository for PostgresInvitationRepository { + async fn create(&self, invitation: &Invitation) -> Result { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + INSERT INTO invitations (id, email, token_hash, inviter_id, status, metadata, expires_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + "#, + ) + .bind(invitation.id.as_str()) + .bind(&invitation.email) + .bind(&invitation.token_hash) + .bind(invitation.inviter_id.as_ref().map(|id| id.as_str())) + .bind(invitation.status.as_str()) + .bind(&invitation.metadata) + .bind(invitation.expires_at) + .bind(invitation.created_at) + .bind(invitation.updated_at) + .fetch_one(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to create invitation"); + Error::Storage(StorageError::Database("Failed to create invitation".to_string())) + })?; + + Ok(pg_invitation.into()) + } + + async fn find_by_id(&self, id: &InvitationId) -> Result, Error> { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + SELECT id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + FROM invitations + WHERE id = $1 + "#, + ) + .bind(id.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to find invitation by ID"); + Error::Storage(StorageError::Database("Failed to find invitation by ID".to_string())) + })?; + + Ok(pg_invitation.map(|inv| inv.into())) + } + + async fn find_by_token_hash(&self, token_hash: &str) -> Result, Error> { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + SELECT id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + FROM invitations + WHERE token_hash = $1 + "#, + ) + .bind(token_hash) + .fetch_optional(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to find invitation by token hash"); + Error::Storage(StorageError::Database("Failed to find invitation by token hash".to_string())) + })?; + + Ok(pg_invitation.map(|inv| inv.into())) + } + + async fn find_by_email(&self, email: &str) -> Result, Error> { + let pg_invitations = sqlx::query_as::<_, PostgresInvitation>( + r#" + SELECT id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + FROM invitations + WHERE email = $1 + ORDER BY created_at DESC + "#, + ) + .bind(email) + .fetch_all(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to find invitations by email"); + Error::Storage(StorageError::Database("Failed to find invitations by email".to_string())) + })?; + + Ok(pg_invitations.into_iter().map(|inv| inv.into()).collect()) + } + + async fn find_pending_by_email(&self, email: &str) -> Result, Error> { + let pg_invitations = sqlx::query_as::<_, PostgresInvitation>( + r#" + SELECT id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + FROM invitations + WHERE email = $1 AND status = 'pending' AND expires_at > NOW() + ORDER BY created_at DESC + "#, + ) + .bind(email) + .fetch_all(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to find pending invitations by email"); + Error::Storage(StorageError::Database("Failed to find pending invitations by email".to_string())) + })?; + + Ok(pg_invitations.into_iter().map(|inv| inv.into()).collect()) + } + + async fn find_by_inviter(&self, inviter_id: &UserId) -> Result, Error> { + let pg_invitations = sqlx::query_as::<_, PostgresInvitation>( + r#" + SELECT id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + FROM invitations + WHERE inviter_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(inviter_id.as_str()) + .fetch_all(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to find invitations by inviter"); + Error::Storage(StorageError::Database("Failed to find invitations by inviter".to_string())) + })?; + + Ok(pg_invitations.into_iter().map(|inv| inv.into()).collect()) + } + + async fn update_status( + &self, + id: &InvitationId, + status: InvitationStatus, + ) -> Result { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + UPDATE invitations + SET status = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + "#, + ) + .bind(status.as_str()) + .bind(id.as_str()) + .fetch_one(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to update invitation status"); + Error::Storage(StorageError::Database("Failed to update invitation status".to_string())) + })?; + + Ok(pg_invitation.into()) + } + + async fn accept(&self, id: &InvitationId, accepted_by: &UserId) -> Result { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + UPDATE invitations + SET status = 'accepted', accepted_at = NOW(), accepted_by = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + "#, + ) + .bind(accepted_by.as_str()) + .bind(id.as_str()) + .fetch_one(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to accept invitation"); + Error::Storage(StorageError::Database("Failed to accept invitation".to_string())) + })?; + + Ok(pg_invitation.into()) + } + + async fn revoke(&self, id: &InvitationId) -> Result { + let pg_invitation = sqlx::query_as::<_, PostgresInvitation>( + r#" + UPDATE invitations + SET status = 'revoked', revoked_at = NOW(), updated_at = NOW() + WHERE id = $1 + RETURNING id, email, token_hash, inviter_id, status, metadata, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at + "#, + ) + .bind(id.as_str()) + .fetch_one(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to revoke invitation"); + Error::Storage(StorageError::Database("Failed to revoke invitation".to_string())) + })?; + + Ok(pg_invitation.into()) + } + + async fn delete(&self, id: &InvitationId) -> Result<(), Error> { + sqlx::query("DELETE FROM invitations WHERE id = $1") + .bind(id.as_str()) + .execute(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to delete invitation"); + Error::Storage(StorageError::Database( + "Failed to delete invitation".to_string(), + )) + })?; + + Ok(()) + } + + async fn cleanup_expired(&self) -> Result { + let result = sqlx::query( + r#" + UPDATE invitations + SET status = 'expired', updated_at = NOW() + WHERE status = 'pending' AND expires_at < NOW() + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to cleanup expired invitations"); + Error::Storage(StorageError::Database( + "Failed to cleanup expired invitations".to_string(), + )) + })?; + + Ok(result.rows_affected()) + } + + async fn count_pending_by_email(&self, email: &str) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM invitations + WHERE email = $1 AND status = 'pending' AND expires_at > NOW() + "#, + ) + .bind(email) + .fetch_one(&self.pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to count pending invitations"); + Error::Storage(StorageError::Database( + "Failed to count pending invitations".to_string(), + )) + })?; + + Ok(count as u64) + } +} diff --git a/torii-storage-postgres/src/repositories/mod.rs b/torii-storage-postgres/src/repositories/mod.rs index c7d4838..60cc5b7 100644 --- a/torii-storage-postgres/src/repositories/mod.rs +++ b/torii-storage-postgres/src/repositories/mod.rs @@ -1,6 +1,7 @@ //! Repository implementations for PostgreSQL storage pub mod brute_force; +pub mod invitation; pub mod oauth; pub mod passkey; pub mod password; @@ -10,6 +11,7 @@ pub mod user; use async_trait::async_trait; pub use brute_force::PostgresBruteForceRepository; +pub use invitation::PostgresInvitationRepository; pub use oauth::PostgresOAuthRepository; pub use passkey::PostgresPasskeyRepository; pub use password::PostgresPasswordRepository; @@ -21,9 +23,9 @@ use torii_core::{ Error, error::StorageError, repositories::{ - BruteForceRepositoryProvider, OAuthRepositoryProvider, PasskeyRepositoryProvider, - PasswordRepositoryProvider, RepositoryProvider, SessionRepositoryProvider, - TokenRepositoryProvider, UserRepositoryProvider, + BruteForceRepositoryProvider, InvitationRepositoryProvider, OAuthRepositoryProvider, + PasskeyRepositoryProvider, PasswordRepositoryProvider, RepositoryProvider, + SessionRepositoryProvider, TokenRepositoryProvider, UserRepositoryProvider, }, }; pub use user::PostgresUserRepository; @@ -41,6 +43,7 @@ pub struct PostgresRepositoryProvider { passkey: Arc, token: Arc, brute_force: Arc, + invitation: Arc, } impl PostgresRepositoryProvider { @@ -53,6 +56,7 @@ impl PostgresRepositoryProvider { let passkey = Arc::new(PostgresPasskeyRepository::new(pool.clone())); let token = Arc::new(PostgresTokenRepository::new(pool.clone())); let brute_force = Arc::new(PostgresBruteForceRepository::new(pool.clone())); + let invitation = Arc::new(PostgresInvitationRepository::new(pool.clone())); Self { pool, @@ -63,6 +67,7 @@ impl PostgresRepositoryProvider { passkey, token, brute_force, + invitation, } } } @@ -125,13 +130,22 @@ impl BruteForceRepositoryProvider for PostgresRepositoryProvider { } } +impl InvitationRepositoryProvider for PostgresRepositoryProvider { + type InvitationRepo = PostgresInvitationRepository; + + fn invitation(&self) -> &Self::InvitationRepo { + &self.invitation + } +} + // Implement the unified RepositoryProvider trait #[async_trait] impl RepositoryProvider for PostgresRepositoryProvider { async fn migrate(&self) -> Result<(), Error> { use crate::migrations::{ - AddLockedAtToUsers, AddPasskeyMetadata, CreateFailedLoginAttemptsTable, CreateIndexes, + AddLockedAtToUsers, AddPasskeyMetadata, AddUserStatusAndInvitedBy, + CreateFailedLoginAttemptsTable, CreateIndexes, CreateInvitationsTable, CreateOAuthAccountsTable, CreateOAuthStateTable, CreatePasskeyChallengesTable, CreatePasskeysTable, CreateSecureTokensTable, CreateSessionsTable, CreateUsersTable, PostgresMigrationManager, @@ -158,6 +172,8 @@ impl RepositoryProvider for PostgresRepositoryProvider { Box::new(CreateOAuthStateTable), Box::new(CreateSecureTokensTable), Box::new(AddPasskeyMetadata), + Box::new(AddUserStatusAndInvitedBy), + Box::new(CreateInvitationsTable), ]; manager.up(&migrations).await.map_err(|e| { tracing::error!(error = %e, "Failed to run migrations"); diff --git a/torii-storage-postgres/src/repositories/user.rs b/torii-storage-postgres/src/repositories/user.rs index 21a2afe..8e5dfe3 100644 --- a/torii-storage-postgres/src/repositories/user.rs +++ b/torii-storage-postgres/src/repositories/user.rs @@ -26,15 +26,17 @@ impl UserRepository for PostgresUserRepository { async fn create(&self, user: NewUser) -> Result { let pg_user = sqlx::query_as::<_, PostgresUser>( r#" - INSERT INTO users (id, email, name, email_verified_at) - VALUES ($1, $2, $3, $4) - RETURNING id, email, name, email_verified_at, locked_at, created_at, updated_at + INSERT INTO users (id, email, name, email_verified_at, status, invited_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, email, name, email_verified_at, status, invited_by, locked_at, created_at, updated_at "#, ) .bind(user.id.as_str()) .bind(&user.email) .bind(&user.name) .bind(user.email_verified_at) + .bind(user.status.as_str()) + .bind(user.invited_by.as_ref().map(|id| id.as_str())) .fetch_one(&self.pool) .await .map_err(|e| { @@ -48,7 +50,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, Error> { let pg_user = sqlx::query_as::<_, PostgresUser>( r#" - SELECT id, email, name, email_verified_at, locked_at, created_at, updated_at + SELECT id, email, name, email_verified_at, status, invited_by, locked_at, created_at, updated_at FROM users WHERE id = $1 "#, @@ -69,7 +71,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_email(&self, email: &str) -> Result, Error> { let pg_user = sqlx::query_as::<_, PostgresUser>( r#" - SELECT id, email, name, email_verified_at, locked_at, created_at, updated_at + SELECT id, email, name, email_verified_at, status, invited_by, locked_at, created_at, updated_at FROM users WHERE email = $1 "#, @@ -104,14 +106,16 @@ impl UserRepository for PostgresUserRepository { let pg_user = sqlx::query_as::<_, PostgresUser>( r#" UPDATE users - SET email = $1, name = $2, email_verified_at = $3, locked_at = $4, updated_at = $5 - WHERE id = $6 - RETURNING id, email, name, email_verified_at, locked_at, created_at, updated_at + SET email = $1, name = $2, email_verified_at = $3, status = $4, invited_by = $5, locked_at = $6, updated_at = $7 + WHERE id = $8 + RETURNING id, email, name, email_verified_at, status, invited_by, locked_at, created_at, updated_at "#, ) .bind(&user.email) .bind(&user.name) .bind(user.email_verified_at) + .bind(user.status.as_str()) + .bind(user.invited_by.as_ref().map(|id| id.as_str())) .bind(user.locked_at) .bind(Utc::now()) .bind(user.id.as_str()) diff --git a/torii-storage-seaorm/src/repositories/invitation.rs b/torii-storage-seaorm/src/repositories/invitation.rs new file mode 100644 index 0000000..87fb345 --- /dev/null +++ b/torii-storage-seaorm/src/repositories/invitation.rs @@ -0,0 +1,107 @@ +//! SeaORM implementation of the invitation repository. +//! +//! Note: This is a stub implementation. Full SeaORM support for invitations +//! will be added in a future release. + +use async_trait::async_trait; +use sea_orm::DatabaseConnection; +use torii_core::{ + Error, Invitation, InvitationId, InvitationStatus, UserId, error::StorageError, + repositories::InvitationRepository, +}; + +/// SeaORM repository for invitation data. +/// +/// Note: This is currently a stub implementation that returns errors +/// for all operations. Full SeaORM support will be added in a future release. +#[derive(Clone)] +pub struct SeaORMInvitationRepository { + #[allow(dead_code)] + pool: DatabaseConnection, +} + +impl SeaORMInvitationRepository { + /// Create a new SeaORM invitation repository. + pub fn new(pool: DatabaseConnection) -> Self { + Self { pool } + } +} + +#[async_trait] +impl InvitationRepository for SeaORMInvitationRepository { + async fn create(&self, _invitation: &Invitation) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn find_by_id(&self, _id: &InvitationId) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn find_by_token_hash(&self, _token_hash: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn find_by_email(&self, _email: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn find_pending_by_email(&self, _email: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn find_by_inviter(&self, _inviter_id: &UserId) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn update_status( + &self, + _id: &InvitationId, + _status: InvitationStatus, + ) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn accept(&self, _id: &InvitationId, _accepted_by: &UserId) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn revoke(&self, _id: &InvitationId) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn delete(&self, _id: &InvitationId) -> Result<(), Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn cleanup_expired(&self) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } + + async fn count_pending_by_email(&self, _email: &str) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SeaORM".to_string(), + ))) + } +} diff --git a/torii-storage-seaorm/src/repositories/mod.rs b/torii-storage-seaorm/src/repositories/mod.rs index 3b32a74..cadf37a 100644 --- a/torii-storage-seaorm/src/repositories/mod.rs +++ b/torii-storage-seaorm/src/repositories/mod.rs @@ -1,6 +1,7 @@ //! Repository implementations for SeaORM storage pub mod brute_force; +pub mod invitation; pub mod oauth; pub mod passkey; pub mod password; @@ -9,6 +10,7 @@ pub mod token; pub mod user; pub use brute_force::SeaORMBruteForceRepository; +pub use invitation::SeaORMInvitationRepository; pub use oauth::SeaORMOAuthRepository; pub use passkey::SeaORMPasskeyRepository; pub use password::SeaORMPasswordRepository; @@ -24,9 +26,9 @@ use torii_core::{ Error, error::StorageError, repositories::{ - BruteForceRepositoryProvider, OAuthRepositoryProvider, PasskeyRepositoryProvider, - PasswordRepositoryProvider, RepositoryProvider, SessionRepositoryProvider, - TokenRepositoryProvider, UserRepositoryProvider, + BruteForceRepositoryProvider, InvitationRepositoryProvider, OAuthRepositoryProvider, + PasskeyRepositoryProvider, PasswordRepositoryProvider, RepositoryProvider, + SessionRepositoryProvider, TokenRepositoryProvider, UserRepositoryProvider, }, }; @@ -44,6 +46,7 @@ pub struct SeaORMRepositoryProvider { passkey: Arc, token: Arc, brute_force: Arc, + invitation: Arc, } impl SeaORMRepositoryProvider { @@ -55,6 +58,7 @@ impl SeaORMRepositoryProvider { let passkey = Arc::new(SeaORMPasskeyRepository::new(pool.clone())); let token = Arc::new(SeaORMTokenRepository::new(pool.clone())); let brute_force = Arc::new(SeaORMBruteForceRepository::new(pool.clone())); + let invitation = Arc::new(SeaORMInvitationRepository::new(pool.clone())); Self { pool, @@ -65,6 +69,7 @@ impl SeaORMRepositoryProvider { passkey, token, brute_force, + invitation, } } } @@ -127,6 +132,14 @@ impl BruteForceRepositoryProvider for SeaORMRepositoryProvider { } } +impl InvitationRepositoryProvider for SeaORMRepositoryProvider { + type InvitationRepo = SeaORMInvitationRepository; + + fn invitation(&self) -> &Self::InvitationRepo { + &self.invitation + } +} + // Implement the unified RepositoryProvider trait #[async_trait] diff --git a/torii-storage-seaorm/src/repositories/user.rs b/torii-storage-seaorm/src/repositories/user.rs index 9952536..6eb9369 100644 --- a/torii-storage-seaorm/src/repositories/user.rs +++ b/torii-storage-seaorm/src/repositories/user.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::Utc; use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; -use torii_core::{Error, User, UserId, repositories::UserRepository, storage::NewUser}; +use torii_core::{Error, User, UserId, UserStatus, repositories::UserRepository, storage::NewUser}; use crate::SeaORMStorageError; use crate::entities::user; @@ -23,6 +23,8 @@ impl SeaORMUserRepository { email: email.to_string(), name: name.map(|s| s.to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; ::create(self, new_user).await @@ -90,6 +92,8 @@ impl UserRepository for SeaORMUserRepository { email: email.to_string(), name: None, email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; ::create(self, new_user).await @@ -167,6 +171,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let result = repo.create(new_user).await; @@ -188,6 +194,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let created_user = repo.create(new_user).await.unwrap(); @@ -218,6 +226,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let _created_user = repo.create(new_user).await.unwrap(); @@ -246,6 +256,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let created_user = repo.create(new_user).await.unwrap(); @@ -283,6 +295,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let mut user = repo.create(new_user).await.unwrap(); @@ -305,6 +319,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let user = repo.create(new_user).await.unwrap(); @@ -326,6 +342,8 @@ mod tests { email: "test@example.com".to_string(), name: Some("Test User".to_string()), email_verified_at: None, + status: UserStatus::Active, + invited_by: None, }; let user = repo.create(new_user).await.unwrap(); diff --git a/torii-storage-seaorm/src/user.rs b/torii-storage-seaorm/src/user.rs index 5d94721..c4f0a45 100644 --- a/torii-storage-seaorm/src/user.rs +++ b/torii-storage-seaorm/src/user.rs @@ -1,6 +1,6 @@ //! SeaORM user types -use torii_core::{User as ToriiUser, UserId}; +use torii_core::{User as ToriiUser, UserId, UserStatus}; use crate::entities::user; @@ -11,6 +11,9 @@ impl From for ToriiUser { name: user.name.to_owned(), email: user.email.to_owned(), email_verified_at: user.email_verified_at.to_owned(), + // SeaORM doesn't have status/invited_by columns yet, default to Active + status: UserStatus::Active, + invited_by: None, locked_at: user.locked_at.to_owned(), created_at: user.created_at.to_owned(), updated_at: user.updated_at.to_owned(), diff --git a/torii-storage-sqlite/src/repositories/invitation.rs b/torii-storage-sqlite/src/repositories/invitation.rs new file mode 100644 index 0000000..a21a387 --- /dev/null +++ b/torii-storage-sqlite/src/repositories/invitation.rs @@ -0,0 +1,106 @@ +//! SQLite implementation of the invitation repository. +//! +//! Note: This is a stub implementation. Full SQLite support for invitations +//! will be added in a future release. + +use async_trait::async_trait; +use sqlx::SqlitePool; +use torii_core::{ + Error, Invitation, InvitationId, InvitationStatus, UserId, error::StorageError, + repositories::InvitationRepository, +}; + +/// SQLite repository for invitation data. +/// +/// Note: This is currently a stub implementation that returns errors +/// for all operations. Full SQLite support will be added in a future release. +pub struct SqliteInvitationRepository { + #[allow(dead_code)] + pool: SqlitePool, +} + +impl SqliteInvitationRepository { + /// Create a new SQLite invitation repository. + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl InvitationRepository for SqliteInvitationRepository { + async fn create(&self, _invitation: &Invitation) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn find_by_id(&self, _id: &InvitationId) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn find_by_token_hash(&self, _token_hash: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn find_by_email(&self, _email: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn find_pending_by_email(&self, _email: &str) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn find_by_inviter(&self, _inviter_id: &UserId) -> Result, Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn update_status( + &self, + _id: &InvitationId, + _status: InvitationStatus, + ) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn accept(&self, _id: &InvitationId, _accepted_by: &UserId) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn revoke(&self, _id: &InvitationId) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn delete(&self, _id: &InvitationId) -> Result<(), Error> { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn cleanup_expired(&self) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } + + async fn count_pending_by_email(&self, _email: &str) -> Result { + Err(Error::Storage(StorageError::Database( + "Invitation repository not yet implemented for SQLite".to_string(), + ))) + } +} diff --git a/torii-storage-sqlite/src/repositories/mod.rs b/torii-storage-sqlite/src/repositories/mod.rs index 044e85d..985a72b 100644 --- a/torii-storage-sqlite/src/repositories/mod.rs +++ b/torii-storage-sqlite/src/repositories/mod.rs @@ -1,6 +1,7 @@ //! Repository implementations for SQLite storage pub mod brute_force; +pub mod invitation; pub mod oauth; pub mod passkey; pub mod password; @@ -9,6 +10,7 @@ pub mod token; pub mod user; pub use brute_force::SqliteBruteForceRepository; +pub use invitation::SqliteInvitationRepository; pub use oauth::SqliteOAuthRepository; pub use passkey::SqlitePasskeyRepository; pub use password::SqlitePasswordRepository; @@ -23,9 +25,9 @@ use torii_core::{ Error, error::StorageError, repositories::{ - BruteForceRepositoryProvider, OAuthRepositoryProvider, PasskeyRepositoryProvider, - PasswordRepositoryProvider, RepositoryProvider, SessionRepositoryProvider, - TokenRepositoryProvider, UserRepositoryProvider, + BruteForceRepositoryProvider, InvitationRepositoryProvider, OAuthRepositoryProvider, + PasskeyRepositoryProvider, PasswordRepositoryProvider, RepositoryProvider, + SessionRepositoryProvider, TokenRepositoryProvider, UserRepositoryProvider, }, }; @@ -42,6 +44,7 @@ pub struct SqliteRepositoryProvider { passkey: Arc, token: Arc, brute_force: Arc, + invitation: Arc, } impl SqliteRepositoryProvider { @@ -53,6 +56,7 @@ impl SqliteRepositoryProvider { let passkey = Arc::new(SqlitePasskeyRepository::new(pool.clone())); let token = Arc::new(SqliteTokenRepository::new(pool.clone())); let brute_force = Arc::new(SqliteBruteForceRepository::new(pool.clone())); + let invitation = Arc::new(SqliteInvitationRepository::new(pool.clone())); Self { pool, @@ -63,6 +67,7 @@ impl SqliteRepositoryProvider { passkey, token, brute_force, + invitation, } } } @@ -125,6 +130,14 @@ impl BruteForceRepositoryProvider for SqliteRepositoryProvider { } } +impl InvitationRepositoryProvider for SqliteRepositoryProvider { + type InvitationRepo = SqliteInvitationRepository; + + fn invitation(&self) -> &Self::InvitationRepo { + &self.invitation + } +} + // Implement the unified RepositoryProvider trait #[async_trait] diff --git a/torii/src/lib.rs b/torii/src/lib.rs index daf8c57..43fde15 100644 --- a/torii/src/lib.rs +++ b/torii/src/lib.rs @@ -125,10 +125,11 @@ use torii_core::{ BruteForceProtectionService, JwtSessionProvider, OpaqueSessionProvider, RepositoryProvider, SessionProvider, repositories::{ - BruteForceProtectionRepositoryAdapter, PasswordRepositoryAdapter, SessionRepositoryAdapter, - TokenRepositoryAdapter, UserRepositoryAdapter, + BruteForceProtectionRepositoryAdapter, InvitationRepositoryAdapter, + PasswordRepositoryAdapter, SessionRepositoryAdapter, TokenRepositoryAdapter, + UserRepositoryAdapter, }, - services::{SessionService, UserService}, + services::{InvitationService, SessionService, UserService}, }; // Re-export builder types @@ -167,8 +168,8 @@ use torii_core::services::{MailerService, ToriiMailerService}; /// /// These types are commonly used when working with the Torii API. pub use torii_core::{ - JwtAlgorithm, JwtClaims, JwtConfig, JwtMetadata, LockoutStatus, Session, SessionToken, User, - UserId, + Invitation, InvitationConfig, InvitationId, InvitationStatus, JwtAlgorithm, JwtClaims, + JwtConfig, JwtMetadata, LockoutStatus, Session, SessionToken, User, UserId, UserStatus, }; /// Re-export storage types @@ -390,6 +391,10 @@ pub struct Torii { email_verification_service: Arc, TokenRepositoryAdapter>>, + /// Invitation service for user invitations + invitation_service: + Arc, UserRepositoryAdapter>>, + session_config: SessionConfig, } @@ -507,12 +512,17 @@ impl Torii { brute_force_service, email_verification_service: Arc::new(EmailVerificationService::new( - user_repo, + user_repo.clone(), Arc::new(torii_core::repositories::TokenRepositoryAdapter::new( repositories.clone(), )), )), + invitation_service: Arc::new(InvitationService::new( + Arc::new(InvitationRepositoryAdapter::new(repositories.clone())), + user_repo, + )), + session_config: SessionConfig::default(), } } @@ -619,12 +629,17 @@ impl Torii { brute_force_service, email_verification_service: Arc::new(EmailVerificationService::new( - user_repo, + user_repo.clone(), Arc::new(torii_core::repositories::TokenRepositoryAdapter::new( repositories.clone(), )), )), + invitation_service: Arc::new(InvitationService::new( + Arc::new(InvitationRepositoryAdapter::new(repositories.clone())), + user_repo, + )), + session_config, }) } @@ -985,6 +1000,227 @@ impl Torii { .await .map_err(|e| ToriiError::StorageError(e.to_string())) } + + // ========================================================================= + // Invitation Methods + // ========================================================================= + + /// Create an invitation for a user to join + /// + /// This creates an invitation that can be sent to a user via email. The invitation + /// includes a secure token that can be used to verify the invitation when the user + /// signs up. + /// + /// # Arguments + /// + /// * `email`: The email address to invite + /// * `inviter_id`: The ID of the user sending the invitation + /// * `invitation_url_base`: The base URL for the invitation link (e.g., "https://example.com/invite"). + /// The token will be appended as a query parameter: `{invitation_url_base}?token={token}` + /// * `metadata`: Optional metadata to store with the invitation (e.g., role, team) + /// + /// # Returns + /// + /// Returns a tuple of (Invitation, provisional_user) where provisional_user is Some if + /// a new provisional user was created for the invitee + pub async fn create_invitation( + &self, + email: &str, + inviter_id: Option<&UserId>, + invitation_url_base: &str, + metadata: Option, + ) -> Result<(Invitation, Option), ToriiError> { + let (invitation, provisional_user) = self + .invitation_service + .create_invitation(email, inviter_id.cloned(), metadata) + .await + .map_err(|e| ToriiError::AuthError(e.to_string()))?; + + // Get the plaintext token from the invitation + let token = invitation + .token() + .expect("Token should be available after creation"); + + // Send invitation email if mailer is configured + #[cfg(feature = "mailer")] + if let Some(mailer) = &self.mailer_service { + let invitation_link = format!( + "{}?token={}", + invitation_url_base.trim_end_matches('/'), + token + ); + + // Get inviter's name for the email + let inviter_name = if let Some(inviter_id) = inviter_id { + self.get_user(inviter_id).await?.and_then(|u| u.name) + } else { + None + }; + + // Calculate expiry in days from the invitation's expires_at + let expires_in_days = (invitation.expires_at - chrono::Utc::now()).num_days(); + + if let Err(e) = mailer + .send_invitation_email( + email, + &invitation_link, + inviter_name.as_deref(), + expires_in_days, + ) + .await + { + tracing::warn!("Failed to send invitation email: {}", e); + // Don't fail the invitation if email sending fails + } + } + + // Suppress unused variable warning when mailer feature is disabled + let _ = invitation_url_base; + + Ok((invitation, provisional_user)) + } + + /// Accept pending invitations for a user after signup + /// + /// This should be called after a user completes signup to automatically accept + /// any pending invitations for their email address. This enables the flow where + /// a user can be invited first and then sign up with any auth method. + /// + /// # Arguments + /// + /// * `user`: The user who just signed up + /// + /// # Returns + /// + /// Returns the list of accepted invitations + pub async fn accept_pending_invitations( + &self, + user: &User, + ) -> Result, ToriiError> { + self.invitation_service + .accept_pending_invitations_for_user(user) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// Verify an invitation token without consuming it + /// + /// This is useful for frontend validation before showing the signup form. + /// + /// # Arguments + /// + /// * `token`: The invitation token to verify + /// + /// # Returns + /// + /// Returns the invitation if the token is valid and not expired + pub async fn verify_invitation_token( + &self, + token: &str, + ) -> Result, ToriiError> { + self.invitation_service + .get_invitation_by_token(token) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// Accept an invitation by token + /// + /// This consumes the invitation token and marks it as accepted. If the user + /// was provisional, they will be activated. + /// + /// # Arguments + /// + /// * `token`: The invitation token + /// * `user_id`: The ID of the user accepting the invitation + /// + /// # Returns + /// + /// Returns a tuple of (accepted invitation, user) + pub async fn accept_invitation( + &self, + token: &str, + user_id: &UserId, + ) -> Result<(Invitation, User), ToriiError> { + self.invitation_service + .accept_invitation(token, user_id) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// Revoke an invitation + /// + /// This marks the invitation as revoked, preventing it from being accepted. + /// + /// # Arguments + /// + /// * `invitation_id`: The ID of the invitation to revoke + /// + /// # Returns + /// + /// Returns the revoked invitation + pub async fn revoke_invitation( + &self, + invitation_id: &InvitationId, + ) -> Result { + self.invitation_service + .revoke_invitation(invitation_id) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// List pending invitations for an email address + /// + /// # Arguments + /// + /// * `email`: The email address to check + /// + /// # Returns + /// + /// Returns a list of pending invitations for the email + pub async fn list_pending_invitations( + &self, + email: &str, + ) -> Result, ToriiError> { + self.invitation_service + .list_pending_invitations(email) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// List all invitations sent by a user + /// + /// # Arguments + /// + /// * `inviter_id`: The ID of the user who sent the invitations + /// + /// # Returns + /// + /// Returns a list of invitations sent by the user + pub async fn list_invitations_by_inviter( + &self, + inviter_id: &UserId, + ) -> Result, ToriiError> { + self.invitation_service + .list_invitations_by_inviter(inviter_id) + .await + .map_err(|e| ToriiError::AuthError(e.to_string())) + } + + /// Clean up expired invitations + /// + /// This removes invitations that have passed their expiration date. + /// Should be called periodically (e.g., via a cron job). + /// + /// # Returns + /// + /// Returns the number of invitations that were expired + pub async fn cleanup_expired_invitations(&self) -> Result { + self.invitation_service + .cleanup_expired() + .await + .map_err(|e| ToriiError::StorageError(e.to_string())) + } } // Password authentication implementation moved to PasswordAuth namespace @@ -1041,6 +1277,12 @@ impl PasswordAuth<'_, R> { .await .map_err(|e| ToriiError::AuthError(e.to_string()))?; + // Accept any pending invitations for this user's email + if let Err(e) = torii.accept_pending_invitations(&user).await { + tracing::warn!("Failed to accept pending invitations: {}", e); + // Don't fail registration if invitation acceptance fails + } + // Send welcome email if mailer is configured #[cfg(feature = "mailer")] if let Some(mailer) = &torii.mailer_service { diff --git a/torii/tests/invitation.rs b/torii/tests/invitation.rs new file mode 100644 index 0000000..dd2b36b --- /dev/null +++ b/torii/tests/invitation.rs @@ -0,0 +1,362 @@ +use std::sync::Arc; + +use torii::{InvitationStatus, Torii, UserStatus}; +use torii_core::repositories::RepositoryProvider; + +// Invitation functionality is only implemented for PostgreSQL currently +// SQLite and SeaORM have stub implementations that return errors + +#[cfg(feature = "postgres")] +use torii::postgres::PostgresRepositoryProvider; + +/// Helper to set up a PostgreSQL test database +#[cfg(feature = "postgres")] +async fn setup_postgres() -> PostgresRepositoryProvider { + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://localhost/torii_test".into()); + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let provider = PostgresRepositoryProvider::new(pool); + provider.migrate().await.unwrap(); + provider +} + +/// Test creating an invitation and verifying its properties +#[cfg(all(feature = "password", feature = "postgres"))] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_create_invitation() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an inviter user first + let inviter_email = "inviter@example.com"; + let inviter_password = "password123"; + let inviter = torii + .password() + .register(inviter_email, inviter_password) + .await + .unwrap(); + + // Create an invitation + let invitee_email = "invitee@example.com"; + let (invitation, provisional_user) = torii + .create_invitation( + invitee_email, + Some(&inviter.id), + "https://example.com/invite", + None, + ) + .await + .unwrap(); + + // Verify invitation properties + assert_eq!(invitation.email, invitee_email); + assert_eq!(invitation.inviter_id.as_ref(), Some(&inviter.id)); + assert_eq!(invitation.status, InvitationStatus::Pending); + assert!(!invitation.is_expired()); + assert!(invitation.can_accept()); + + // Verify the token is available + assert!(invitation.token().is_some()); + + // Verify a provisional user was created + assert!(provisional_user.is_some()); + let provisional = provisional_user.unwrap(); + assert_eq!(provisional.email, invitee_email); + assert_eq!(provisional.status, UserStatus::Provisional); + assert!(provisional.is_provisional()); + assert!(!provisional.is_active()); +} + +/// Test verifying an invitation token +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_verify_invitation_token() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an invitation (without inviter for simplicity) + let invitee_email = "verify@example.com"; + let (invitation, _) = torii + .create_invitation(invitee_email, None, "https://example.com/invite", None) + .await + .unwrap(); + + // Get the token + let token = invitation.token().unwrap(); + + // Verify the token + let verified = torii.verify_invitation_token(token).await.unwrap(); + assert!(verified.is_some()); + let verified_invitation = verified.unwrap(); + assert_eq!(verified_invitation.email, invitee_email); + + // Verify with invalid token fails + let invalid = torii + .verify_invitation_token("invalid_token") + .await + .unwrap(); + assert!(invalid.is_none()); +} + +/// Test accepting an invitation +#[cfg(all(feature = "password", feature = "postgres"))] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_accept_invitation() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an inviter user + let inviter_email = "inviter@example.com"; + let inviter = torii + .password() + .register(inviter_email, "password123") + .await + .unwrap(); + + // Create an invitation + let invitee_email = "newuser@example.com"; + let (invitation, provisional_user) = torii + .create_invitation( + invitee_email, + Some(&inviter.id), + "https://example.com/invite", + None, + ) + .await + .unwrap(); + + // Get the provisional user ID + let provisional = provisional_user.expect("Provisional user should be created"); + let token = invitation.token().unwrap(); + + // Accept the invitation + let (accepted_invitation, activated_user) = torii + .accept_invitation(token, &provisional.id) + .await + .unwrap(); + + // Verify invitation is now accepted + assert_eq!(accepted_invitation.status, InvitationStatus::Accepted); + + // Verify user is now active + assert_eq!(activated_user.status, UserStatus::Active); + assert!(activated_user.is_active()); + assert!(!activated_user.is_provisional()); +} + +/// Test revoking an invitation +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_revoke_invitation() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an invitation + let invitee_email = "revoke@example.com"; + let (invitation, _) = torii + .create_invitation(invitee_email, None, "https://example.com/invite", None) + .await + .unwrap(); + + // Revoke the invitation + let revoked = torii.revoke_invitation(&invitation.id).await.unwrap(); + + // Verify invitation is revoked + assert_eq!(revoked.status, InvitationStatus::Revoked); + assert!(!revoked.can_accept()); + + // Verify the token no longer works + let token = invitation.token().unwrap(); + let verified = torii.verify_invitation_token(token).await.unwrap(); + assert!(verified.is_none()); // Should not return revoked invitations +} + +/// Test listing pending invitations +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_list_pending_invitations() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create multiple invitations for the same email + let email = "multi@example.com"; + let (_inv1, _) = torii + .create_invitation(email, None, "https://example.com/invite", None) + .await + .unwrap(); + let (_inv2, _) = torii + .create_invitation(email, None, "https://example.com/invite", None) + .await + .unwrap(); + + // List pending invitations + let pending = torii.list_pending_invitations(email).await.unwrap(); + assert_eq!(pending.len(), 2); + + // All should be pending + for inv in &pending { + assert_eq!(inv.status, InvitationStatus::Pending); + } +} + +/// Test that invitations by inviter are tracked +#[cfg(all(feature = "password", feature = "postgres"))] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_list_invitations_by_inviter() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an inviter + let inviter = torii + .password() + .register("inviter@example.com", "password123") + .await + .unwrap(); + + // Create invitations from this inviter + torii + .create_invitation( + "user1@example.com", + Some(&inviter.id), + "https://example.com/invite", + None, + ) + .await + .unwrap(); + torii + .create_invitation( + "user2@example.com", + Some(&inviter.id), + "https://example.com/invite", + None, + ) + .await + .unwrap(); + + // List invitations sent by this inviter + let sent = torii + .list_invitations_by_inviter(&inviter.id) + .await + .unwrap(); + assert_eq!(sent.len(), 2); +} + +/// Test auto-accepting pending invitations after signup +#[cfg(all(feature = "password", feature = "postgres"))] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_accept_pending_invitations_after_signup() { + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create an inviter + let inviter = torii + .password() + .register("inviter@example.com", "password123") + .await + .unwrap(); + + // Create an invitation for a user who doesn't exist yet + let new_user_email = "newuser@example.com"; + torii + .create_invitation( + new_user_email, + Some(&inviter.id), + "https://example.com/invite", + None, + ) + .await + .unwrap(); + + // Verify there's a pending invitation + let pending = torii + .list_pending_invitations(new_user_email) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].status, InvitationStatus::Pending); + + // User signs up (not using the invitation link directly, just registering) + let user = torii + .password() + .register(new_user_email, "password123") + .await + .unwrap(); + + // Accept pending invitations for this user + let accepted = torii.accept_pending_invitations(&user).await.unwrap(); + + // Verify invitations were accepted + assert_eq!(accepted.len(), 1); + assert_eq!(accepted[0].status, InvitationStatus::Accepted); + + // Verify no more pending invitations + let remaining = torii + .list_pending_invitations(new_user_email) + .await + .unwrap(); + assert_eq!(remaining.len(), 0); +} + +/// Test invitation with metadata +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "Requires PostgreSQL database"] +async fn test_invitation_with_metadata() { + use serde_json::json; + + // Set up PostgreSQL storage + let repositories = setup_postgres().await; + + // Create Torii instance + let torii = Torii::new(Arc::new(repositories)); + + // Create invitation with metadata + let metadata = json!({ + "role": "admin", + "team": "engineering" + }); + + let (invitation, _) = torii + .create_invitation( + "metadata@example.com", + None, + "https://example.com/invite", + Some(metadata.clone()), + ) + .await + .unwrap(); + + // Verify metadata was stored + assert!(invitation.metadata.is_some()); + let stored = invitation.metadata.unwrap(); + assert_eq!(stored["role"], "admin"); + assert_eq!(stored["team"], "engineering"); +}