diff --git a/crates/api/src/app.rs b/crates/api/src/app.rs index 290bf36..d48f4d3 100644 --- a/crates/api/src/app.rs +++ b/crates/api/src/app.rs @@ -2,6 +2,7 @@ use crate::config::swagger_config::ApiDoc; use crate::handlers::{ add_bank::add_bank_account, all_banks::all_banks, + audit_logs::get_user_audit_logs, current_user::current_user_details, exchange_rate::get_exchange_rate as get_exchange_rate_handler, get_transaction::get_transactions, @@ -21,6 +22,7 @@ use crate::handlers::{ user_bank_accounts::user_bank_accounts, user_transaction::get_user_transaction, user_wallets::get_user_wallets, + verify_email::{resend_verification, verify_email}, withdraw::withdraw, }; use axum::{ @@ -138,6 +140,8 @@ fn create_secured_routers(state: &Arc) -> Router> { "/api/wallet/withdraw/{bank_account_id}", axum::routing::post(withdraw), ) + .route("/api/user/audit-logs", get(get_user_audit_logs)) + .route("/api/auth/resend-verification", post(resend_verification)) .layer(middleware::from_fn_with_state( state.clone(), SecurityConfig::auth_middleware, @@ -161,6 +165,7 @@ fn create_public_routers(metric_handle: PrometheusHandle) -> Router, + pub size: Option, +} + +pub async fn get_user_audit_logs( + State(state): State>, + Extension(claims): Extension, + Query(query): Query, +) -> Result, ApiError> { + let user_id = claims.user_id()?; + let limit = query.size.unwrap_or(20).min(100); + let offset = (query.page.unwrap_or(1) - 1) * limit; + + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + + let logs = AuditLogRepository::find_by_user_paginated(&mut conn, user_id, limit, offset)?; + + Ok(Json(serde_json::json!({ + "status": "success", + "data": logs, + "page": query.page.unwrap_or(1), + "limit": limit + }))) +} diff --git a/crates/api/src/handlers/mod.rs b/crates/api/src/handlers/mod.rs index a8d1ab5..84fd4d1 100755 --- a/crates/api/src/handlers/mod.rs +++ b/crates/api/src/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod add_bank; pub mod all_banks; +pub mod audit_logs; pub mod current_user; pub mod delete_bank; pub mod exchange_rate; @@ -16,7 +17,6 @@ pub mod refresh_token; pub mod register; pub mod resolve_account; pub mod resolve_user; -pub mod send_verification; pub mod social_login; pub mod stripe_webhook; pub mod top_up; @@ -25,4 +25,5 @@ pub mod transfer_internal; pub mod user_bank_accounts; pub mod user_transaction; pub mod user_wallets; +pub mod verify_email; pub mod withdraw; diff --git a/crates/api/src/handlers/verify_email.rs b/crates/api/src/handlers/verify_email.rs new file mode 100644 index 0000000..9047c19 --- /dev/null +++ b/crates/api/src/handlers/verify_email.rs @@ -0,0 +1,46 @@ +use axum::{extract::Query, extract::State, Extension, Json}; +use payego_core::app_state::AppState; +use payego_core::security::Claims; +use payego_core::services::auth_service::verification::VerificationService; +use payego_primitives::error::{ApiError, AuthError}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Deserialize)] +pub struct VerifyEmailQuery { + pub token: String, +} + +pub async fn verify_email( + State(state): State>, + Query(query): Query, +) -> Result, ApiError> { + VerificationService::verify_email(&state, &query.token).await?; + + Ok(Json(serde_json::json!({ + "status": "success", + "message": "Email verified successfully" + }))) +} + +pub async fn resend_verification( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + let user_id = claims.user_id()?; + + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + let user = + payego_core::repositories::user_repository::UserRepository::find_by_id(&mut conn, user_id)? + .ok_or_else(|| ApiError::Auth(AuthError::InternalError("User not found".into())))?; + + VerificationService::send_verification_email(&state, user_id, &user.email).await?; + + Ok(Json(serde_json::json!({ + "status": "success", + "message": "Verification email resent" + }))) +} diff --git a/crates/core/src/repositories/audit_repository.rs b/crates/core/src/repositories/audit_repository.rs index b02baea..2de47ec 100644 --- a/crates/core/src/repositories/audit_repository.rs +++ b/crates/core/src/repositories/audit_repository.rs @@ -13,4 +13,19 @@ impl AuditLogRepository { .map_err(ApiError::Database)?; Ok(()) } + + pub fn find_by_user_paginated( + conn: &mut PgConnection, + user_id: uuid::Uuid, + limit: i64, + offset: i64, + ) -> Result, ApiError> { + audit_logs::table + .filter(audit_logs::user_id.eq(user_id)) + .order(audit_logs::created_at.desc()) + .limit(limit) + .offset(offset) + .load::(conn) + .map_err(ApiError::Database) + } } diff --git a/crates/core/src/repositories/mod.rs b/crates/core/src/repositories/mod.rs index edc64a2..579d304 100644 --- a/crates/core/src/repositories/mod.rs +++ b/crates/core/src/repositories/mod.rs @@ -4,4 +4,5 @@ pub mod bank_repository; pub mod token_repository; pub mod transaction_repository; pub mod user_repository; +pub mod verification_repository; pub mod wallet_repository; diff --git a/crates/core/src/repositories/user_repository.rs b/crates/core/src/repositories/user_repository.rs index 345a377..541a0cd 100644 --- a/crates/core/src/repositories/user_repository.rs +++ b/crates/core/src/repositories/user_repository.rs @@ -55,4 +55,12 @@ impl UserRepository { } }) } + + pub fn mark_email_verified(conn: &mut PgConnection, user_id: Uuid) -> Result<(), ApiError> { + diesel::update(users::table.find(user_id)) + .set(users::email_verified_at.eq(chrono::Utc::now())) + .execute(conn) + .map(|_| ()) + .map_err(ApiError::Database) + } } diff --git a/crates/core/src/repositories/verification_repository.rs b/crates/core/src/repositories/verification_repository.rs new file mode 100644 index 0000000..9dab34d --- /dev/null +++ b/crates/core/src/repositories/verification_repository.rs @@ -0,0 +1,63 @@ +use crate::repositories::user_repository::UserRepository; +use diesel::prelude::*; +use payego_primitives::error::{ApiError, AuthError}; +use payego_primitives::models::entities::verification_token::{ + NewVerificationToken, VerificationToken, +}; +use payego_primitives::schema::verification_tokens; +use uuid::Uuid; + +pub struct VerificationRepository; + +impl VerificationRepository { + pub fn create( + conn: &mut PgConnection, + new_token: NewVerificationToken, + ) -> Result { + diesel::insert_into(verification_tokens::table) + .values(&new_token) + .get_result(conn) + .map_err(ApiError::Database) + } + + pub fn find_by_token( + conn: &mut PgConnection, + token_hash: &str, + ) -> Result, ApiError> { + verification_tokens::table + .filter(verification_tokens::token_hash.eq(token_hash)) + .first::(conn) + .optional() + .map_err(ApiError::Database) + } + + pub fn delete_for_user(conn: &mut PgConnection, user_id: Uuid) -> Result<(), ApiError> { + diesel::delete(verification_tokens::table.filter(verification_tokens::user_id.eq(user_id))) + .execute(conn) + .map(|_| ()) + .map_err(ApiError::Database) + } + + pub fn consume_token( + conn: &mut PgConnection, + token_hash: &str, + ) -> Result { + let token = Self::find_by_token(conn, token_hash)?.ok_or_else(|| { + ApiError::Auth(AuthError::VerificationError( + "Invalid or expired verification token".into(), + )) + })?; + + if token.expires_at < chrono::Utc::now().naive_utc() { + return Err(ApiError::Auth(AuthError::VerificationError( + "Verification token has expired".into(), + ))); + } + + // Verify user and delete token + UserRepository::mark_email_verified(conn, token.user_id)?; + Self::delete_for_user(conn, token.user_id)?; + + Ok(token) + } +} diff --git a/crates/core/src/services/auth_service/mod.rs b/crates/core/src/services/auth_service/mod.rs index f707aca..a0a0f86 100644 --- a/crates/core/src/services/auth_service/mod.rs +++ b/crates/core/src/services/auth_service/mod.rs @@ -3,3 +3,4 @@ pub mod logout; pub mod register; pub mod token; pub mod user; +pub mod verification; diff --git a/crates/core/src/services/auth_service/register.rs b/crates/core/src/services/auth_service/register.rs index 6cf358f..153626f 100644 --- a/crates/core/src/services/auth_service/register.rs +++ b/crates/core/src/services/auth_service/register.rs @@ -53,6 +53,17 @@ impl RegisterService { ApiError::Internal("Authentication service error".into()) })?; + // 📧 Trigger Email Verification + let _ = Box::pin(crate::services::auth_service::verification::VerificationService::send_verification_email( + state, + user.id, + &user.email, + )) + .await + .map_err(|e| { + error!(user_id = %user.id, "Failed to send verification email: {}", e); + }); + let _ = AuditService::log_event( state, Some(user.id), diff --git a/crates/core/src/services/auth_service/verification.rs b/crates/core/src/services/auth_service/verification.rs new file mode 100644 index 0000000..5b6eb8a --- /dev/null +++ b/crates/core/src/services/auth_service/verification.rs @@ -0,0 +1,65 @@ +use crate::app_state::AppState; +use crate::repositories::verification_repository::VerificationRepository; +use payego_primitives::error::ApiError; +use payego_primitives::models::entities::verification_token::NewVerificationToken; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +pub struct VerificationService; + +impl VerificationService { + pub async fn send_verification_email( + state: &AppState, + user_id: Uuid, + email: &str, + ) -> Result<(), ApiError> { + let token = Uuid::new_v4().to_string(); + let token_hash = Self::hash_token(&token); + + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + + // 24 hour expiry + let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::hours(24); + + VerificationRepository::delete_for_user(&mut conn, user_id)?; + VerificationRepository::create( + &mut conn, + NewVerificationToken { + user_id, + token_hash, + expires_at, + }, + )?; + + let subject = "Verify your email - Payego"; + let body = format!( + "Please verify your email by clicking here: /verify-email?token={}", + token + ); + + state.email.send_email(email, subject, &body).await?; + + Ok(()) + } + + pub async fn verify_email(state: &AppState, token: &str) -> Result<(), ApiError> { + let token_hash = Self::hash_token(token); + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + + VerificationRepository::consume_token(&mut conn, &token_hash)?; + + Ok(()) + } + + fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) + } +} diff --git a/crates/core/src/services/conversion_service.rs b/crates/core/src/services/conversion_service.rs index 57decda..9457f82 100644 --- a/crates/core/src/services/conversion_service.rs +++ b/crates/core/src/services/conversion_service.rs @@ -33,27 +33,6 @@ impl ConversionService { .get() .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; - // ---------- IDEMPOTENCY ---------- - if let Some(tx) = TransactionRepository::find_by_idempotency_key( - &mut conn, - user_id, - &req.idempotency_key, - )? { - let get_i64 = |key: &str| { - tx.metadata - .get(key) - .and_then(|v| v.as_i64()) - .ok_or_else(|| ApiError::Internal(format!("Missing/invalid {}", key))) - }; - - return Ok(ConvertResponse { - transaction_id: tx.reference.to_string(), - converted_amount: get_i64("converted_amount_cents")? as f64 / 100.0, - exchange_rate: get_i64("exchange_rate_scaled")? as f64 / 1_000_000.0, - fee: get_i64("fee_cents")? as f64 / 100.0, - }); - } - // ---------- RATE ---------- let rate = state .fx @@ -72,87 +51,110 @@ impl ConversionService { let tx_ref = Uuid::new_v4(); - conn.transaction::<_, ApiError, _>(|conn| { - let from_wallet = WalletRepository::find_by_user_and_currency_with_lock( - conn, - user_id, - req.from_currency, - )?; - let to_wallet = WalletRepository::create_if_not_exists(conn, user_id, req.to_currency)?; - - if from_wallet.balance < req.amount_cents { - return Err(ApiError::Payment("Insufficient balance".into())); - } - - let tx = TransactionRepository::create( - conn, - NewTransaction { + let (tx_id, actual_net_cents, actual_rate, actual_fee) = conn + .transaction::<_, ApiError, _>(|conn| { + // 🔒 IDEMPOTENCY + if let Some(tx) = TransactionRepository::find_by_idempotency_key( + conn, + user_id, + &req.idempotency_key, + )? { + let get_i64 = |key: &str| { + tx.metadata + .get(key) + .and_then(|v| v.as_i64()) + .ok_or_else(|| ApiError::Internal(format!("Missing/invalid {}", key))) + }; + + return Ok(( + tx.id, + get_i64("converted_amount_cents")?, + get_i64("exchange_rate_scaled")? as f64 / 1_000_000.0, + get_i64("fee_cents")?, + )); + } + + let from_wallet = WalletRepository::find_by_user_and_currency_with_lock( + conn, user_id, - counterparty_id: None, - intent: TransactionIntent::Conversion, - amount: req.amount_cents, - currency: req.from_currency, - txn_state: PaymentState::Completed, - provider: Some(PaymentProvider::Internal), - provider_reference: None, - idempotency_key: &req.idempotency_key, - reference: tx_ref, - description: Some("Currency conversion"), - metadata: json!({ - "exchange_rate_scaled": rate_scaled, - "converted_amount_cents": net_cents, - "fee_cents": fee_cents, - "quoted_at": chrono::Utc::now() - }), - }, - )?; - - WalletRepository::add_ledger_entry( - conn, - payego_primitives::models::wallet_ledger::NewWalletLedger { - wallet_id: from_wallet.id, - transaction_id: tx.id, - amount: -req.amount_cents, - }, - )?; - - WalletRepository::add_ledger_entry( - conn, - payego_primitives::models::wallet_ledger::NewWalletLedger { - wallet_id: to_wallet.id, - transaction_id: tx.id, - amount: net_cents, - }, - )?; - - WalletRepository::debit(conn, from_wallet.id, req.amount_cents)?; - WalletRepository::credit(conn, to_wallet.id, net_cents)?; - - Ok(()) - })?; + req.from_currency, + )?; + let to_wallet = + WalletRepository::create_if_not_exists(conn, user_id, req.to_currency)?; + + if from_wallet.balance < req.amount_cents { + return Err(ApiError::Payment("Insufficient balance".into())); + } + + let tx = TransactionRepository::create( + conn, + NewTransaction { + user_id, + counterparty_id: None, + intent: TransactionIntent::Conversion, + amount: req.amount_cents, + currency: req.from_currency, + txn_state: PaymentState::Completed, + provider: Some(PaymentProvider::Internal), + provider_reference: None, + idempotency_key: &req.idempotency_key, + reference: tx_ref, + description: Some("Currency conversion"), + metadata: json!({ + "exchange_rate_scaled": rate_scaled, + "converted_amount_cents": net_cents, + "fee_cents": fee_cents, + "quoted_at": chrono::Utc::now() + }), + }, + )?; + + WalletRepository::add_ledger_entry( + conn, + payego_primitives::models::wallet_ledger::NewWalletLedger { + wallet_id: from_wallet.id, + transaction_id: tx.id, + amount: -req.amount_cents, + }, + )?; + + WalletRepository::add_ledger_entry( + conn, + payego_primitives::models::wallet_ledger::NewWalletLedger { + wallet_id: to_wallet.id, + transaction_id: tx.id, + amount: net_cents, + }, + )?; + + WalletRepository::debit(conn, from_wallet.id, req.amount_cents)?; + WalletRepository::credit(conn, to_wallet.id, net_cents)?; + + Ok::<(Uuid, i64, f64, i64), ApiError>((tx.id, net_cents, rate, fee_cents)) + })?; let _ = AuditService::log_event( state, Some(user_id), "conversion.internal", Some("transaction"), - Some(&tx_ref.to_string()), + Some(&tx_id.to_string()), json!({ "from": req.from_currency, "to": req.to_currency, "amount": req.amount_cents, - "converted": net_cents, - "rate": rate, + "converted": actual_net_cents, + "rate": actual_rate, }), None, ) .await; Ok(ConvertResponse { - transaction_id: tx_ref.to_string(), - converted_amount: net_cents as f64 / 100.0, - exchange_rate: rate, - fee: fee_cents as f64 / 100.0, + transaction_id: tx_id.to_string(), + converted_amount: actual_net_cents as f64 / 100.0, + exchange_rate: actual_rate, + fee: actual_fee as f64 / 100.0, }) } diff --git a/crates/core/src/services/transfer_service.rs b/crates/core/src/services/transfer_service.rs index 0ad464c..e9b5142 100644 --- a/crates/core/src/services/transfer_service.rs +++ b/crates/core/src/services/transfer_service.rs @@ -172,7 +172,15 @@ impl TransferService { if let Some(existing) = TransactionRepository::find_by_idempotency_key(conn, user_id, &req.idempotency_key)? { - return Ok(existing.id); + if existing.txn_state == PaymentState::Completed { + return Ok(existing.id); + } + if existing.txn_state == PaymentState::Pending { + return Ok(existing.id); + } + return Err(ApiError::Payment( + "Transaction already exists with a different state".into(), + )); } let wallet = @@ -204,6 +212,8 @@ impl TransferService { }, )?; + WalletRepository::debit(conn, wallet.id, amount_minor)?; + WalletRepository::add_ledger_entry( conn, NewWalletLedger { @@ -213,8 +223,6 @@ impl TransferService { }, )?; - WalletRepository::debit(conn, wallet.id, amount_minor)?; - Ok::(tx.id) })?; diff --git a/crates/core/src/services/withdrawal_service.rs b/crates/core/src/services/withdrawal_service.rs index 5333c98..54cb068 100644 --- a/crates/core/src/services/withdrawal_service.rs +++ b/crates/core/src/services/withdrawal_service.rs @@ -36,16 +36,6 @@ impl WithdrawalService { .get() .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; - let wallet = WalletRepository::find_by_user_and_currency_with_lock( - &mut conn, - user_id, - req.currency, - )?; - - if wallet.balance < amount_minor { - return Err(ApiError::Payment("Insufficient balance".into())); - } - let bank_account = BankAccountRepository::find_verified_by_id_and_user( &mut conn, bank_account_id, @@ -59,16 +49,34 @@ impl WithdrawalService { ApiError::Payment("Bank account is not linked to a provider recipient".into()) })?; - // External transfer - state - .paystack - .initiate_transfer(recipient_code, amount_minor, &req.reference.to_string()) - .await?; + // 1. Transactional DB setup + let (tx_id, currency) = conn.transaction::<_, ApiError, _>(|conn| { + // 2. Idempotency Check + if let Some(existing) = + TransactionRepository::find_by_idempotency_key(conn, user_id, &req.idempotency_key)? + { + if existing.txn_state == PaymentState::Completed { + return Ok((existing.id, existing.currency)); + } + // If it's Pending, we'll fall through and retry the external call + if existing.txn_state == PaymentState::Pending { + return Ok((existing.id, existing.currency)); + } - // Atomic DB write - let tx_id = conn.transaction::<_, ApiError, _>(|conn| { - WalletRepository::debit(conn, wallet.id, amount_minor)?; + return Err(ApiError::Payment( + "Transaction already exists with a different state".into(), + )); + } + // 3. Wallet Lock & Balance Check + let wallet = + WalletRepository::find_by_user_and_currency_with_lock(conn, user_id, req.currency)?; + + if wallet.balance < amount_minor { + return Err(ApiError::Payment("Insufficient balance".into())); + } + + // 4. Create PENDING Transaction & Debit Wallet let tx = TransactionRepository::create( conn, NewTransaction { @@ -77,9 +85,9 @@ impl WithdrawalService { intent: TransactionIntent::Payout, amount: amount_minor, currency: wallet.currency, - txn_state: PaymentState::Completed, // Simplified + txn_state: PaymentState::Pending, provider: Some(PaymentProvider::Paystack), - provider_reference: Some(recipient_code), + provider_reference: None, idempotency_key: &req.idempotency_key, reference: req.reference, description: Some("Wallet withdrawal"), @@ -90,7 +98,8 @@ impl WithdrawalService { }, )?; - // Ledger + WalletRepository::debit(conn, wallet.id, amount_minor)?; + WalletRepository::add_ledger_entry( conn, NewWalletLedger { @@ -100,9 +109,34 @@ impl WithdrawalService { }, )?; - Ok::(tx.id) + Ok::<(Uuid, payego_primitives::models::enum_types::CurrencyCode), ApiError>(( + tx.id, + wallet.currency, + )) })?; + // 5. External transfer (Safe to retry if Pending) + let paystack_result = state + .paystack + .initiate_transfer(recipient_code, amount_minor, &req.reference.to_string()) + .await; + + match paystack_result { + Ok(_) => { + // 6. Complete Transaction + TransactionRepository::update_status_and_provider_ref( + &mut conn, + tx_id, + PaymentState::Completed, + Some(recipient_code.to_string()), + )?; + } + Err(e) => { + tracing::error!(error = %e, transaction_id = %tx_id, "Paystack transfer call failed"); + return Err(e); + } + } + let _ = AuditService::log_event( state, Some(user_id), @@ -111,7 +145,7 @@ impl WithdrawalService { Some(&tx_id.to_string()), json!({ "amount": amount_minor, - "currency": wallet.currency, + "currency": currency, "bank_account_id": bank_account_id, }), None, diff --git a/crates/primitives/src/error.rs b/crates/primitives/src/error.rs index 14fcee0..2e5a010 100755 --- a/crates/primitives/src/error.rs +++ b/crates/primitives/src/error.rs @@ -155,6 +155,7 @@ impl From for (StatusCode, String) { AuthError::DuplicateEmail => { (StatusCode::BAD_REQUEST, "Email already exist".to_string()) } + AuthError::VerificationError(msg) => (StatusCode::BAD_REQUEST, msg), }, ApiError::Payment(msg) => ( StatusCode::INTERNAL_SERVER_ERROR, @@ -258,6 +259,15 @@ impl IntoResponse for ApiError { }, ), + ApiError::Auth(AuthError::VerificationError(msg)) => ( + StatusCode::BAD_REQUEST, + ApiErrorResponse { + code: "VERIFICATION_ERROR".to_string(), + message: msg, + details: None, + }, + ), + // ── Validation & input errors ── ApiError::Validation(errors) => ( StatusCode::BAD_REQUEST, @@ -401,6 +411,7 @@ pub enum AuthError { BlacklistedToken, InternalError(String), DuplicateEmail, + VerificationError(String), } impl fmt::Display for AuthError { @@ -413,6 +424,7 @@ impl fmt::Display for AuthError { AuthError::BlacklistedToken => write!(f, "Token has been invalidated"), AuthError::InternalError(msg) => write!(f, "Internal error: {}", msg), AuthError::DuplicateEmail => write!(f, "Email already exist"), + AuthError::VerificationError(msg) => write!(f, "Verification error: {}", msg), } } } diff --git a/crates/primitives/src/models/entities/mod.rs b/crates/primitives/src/models/entities/mod.rs index 0ed3465..bf08f45 100644 --- a/crates/primitives/src/models/entities/mod.rs +++ b/crates/primitives/src/models/entities/mod.rs @@ -5,5 +5,6 @@ pub mod bank; pub mod enum_types; pub mod transaction; pub mod user; +pub mod verification_token; pub mod wallet; pub mod wallet_ledger; diff --git a/crates/primitives/src/models/entities/user.rs b/crates/primitives/src/models/entities/user.rs index fb5211a..b9485fb 100644 --- a/crates/primitives/src/models/entities/user.rs +++ b/crates/primitives/src/models/entities/user.rs @@ -12,6 +12,7 @@ pub struct User { pub username: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub email_verified_at: Option>, } #[derive(Insertable, Deserialize)] diff --git a/crates/primitives/src/models/entities/verification_token.rs b/crates/primitives/src/models/entities/verification_token.rs new file mode 100644 index 0000000..bc8fb2a --- /dev/null +++ b/crates/primitives/src/models/entities/verification_token.rs @@ -0,0 +1,24 @@ +use crate::schema::verification_tokens; +use chrono::NaiveDateTime; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable, Associations)] +#[diesel(belongs_to(crate::models::entities::user::User))] +#[diesel(table_name = verification_tokens)] +pub struct VerificationToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expires_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Deserialize, Insertable)] +#[diesel(table_name = verification_tokens)] +pub struct NewVerificationToken { + pub user_id: Uuid, + pub token_hash: String, + pub expires_at: NaiveDateTime, +} diff --git a/crates/primitives/src/schema.rs b/crates/primitives/src/schema.rs index 1d5ae05..00e1d3a 100755 --- a/crates/primitives/src/schema.rs +++ b/crates/primitives/src/schema.rs @@ -113,6 +113,17 @@ diesel::table! { username -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, + email_verified_at -> Nullable, + } +} + +diesel::table! { + verification_tokens (id) { + id -> Uuid, + user_id -> Uuid, + token_hash -> Text, + expires_at -> Timestamptz, + created_at -> Timestamptz, } } @@ -143,6 +154,7 @@ diesel::table! { diesel::joinable!(audit_logs -> users (user_id)); diesel::joinable!(bank_accounts -> users (user_id)); diesel::joinable!(refresh_tokens -> users (user_id)); +diesel::joinable!(verification_tokens -> users (user_id)); diesel::joinable!(wallet_ledger -> transactions (transaction_id)); diesel::joinable!(wallet_ledger -> wallets (wallet_id)); diesel::joinable!(wallets -> users (user_id)); @@ -155,6 +167,7 @@ diesel::allow_tables_to_appear_in_same_query!( refresh_tokens, transactions, users, + verification_tokens, wallet_ledger, wallets, ); diff --git a/diesel.toml b/diesel.toml index c3b249e..f0c946c 100644 --- a/diesel.toml +++ b/diesel.toml @@ -3,7 +3,7 @@ [print_schema] file = "crates/primitives/src/schema.rs" -custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] +custom_type_derives = ["diesel::query_builder::QueryId"] [migrations_directory] dir = "migrations" diff --git a/migrations/2026-01-28-152500_create_audit_logs/up.sql b/migrations/2026-01-28-152500_create_audit_logs/up.sql index 9110481..4b493eb 100644 --- a/migrations/2026-01-28-152500_create_audit_logs/up.sql +++ b/migrations/2026-01-28-152500_create_audit_logs/up.sql @@ -1,4 +1,4 @@ -CREATE TABLE audit_logs ( +CREATE TABLE IF NOT EXISTS audit_logs ( id UUID PRIMARY KEY, user_id UUID REFERENCES users(id), event_type TEXT NOT NULL, @@ -9,6 +9,6 @@ CREATE TABLE audit_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); -CREATE INDEX idx_audit_logs_event_type ON audit_logs(event_type); -CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit_logs(event_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); diff --git a/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/down.sql b/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/down.sql new file mode 100644 index 0000000..002ca53 --- /dev/null +++ b/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_audit_logs_user_id_created_at; +DROP TABLE IF EXISTS verification_tokens; +ALTER TABLE users DROP COLUMN IF EXISTS email_verified_at; diff --git a/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/up.sql b/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/up.sql new file mode 100644 index 0000000..0c3fb0a --- /dev/null +++ b/migrations/2026-01-28-181124-0000_add_verification_support_and_audit_retrieval_indices/up.sql @@ -0,0 +1,14 @@ +-- Add email verification support +ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ; + +-- Create verification tokens table +CREATE TABLE verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Optimize audit log retrieval for users +CREATE INDEX idx_audit_logs_user_id_created_at ON audit_logs(user_id, created_at DESC); diff --git a/payego_ui/src/App.tsx b/payego_ui/src/App.tsx index 8cd3b33..3658fe0 100644 --- a/payego_ui/src/App.tsx +++ b/payego_ui/src/App.tsx @@ -16,6 +16,7 @@ import Wallets from './components/Wallets'; import Transactions from './components/Transactions'; import Profile from './components/Profile'; import Sidebar from './components/Sidebar'; +import VerifyEmail from './pages/VerifyEmail'; import { useState } from 'react'; import { ThemeProvider } from './contexts/ThemeContext'; import ThemeToggle from './components/ThemeToggle'; @@ -117,6 +118,7 @@ function App() { } /> } /> } /> + } /> diff --git a/payego_ui/src/api/auth.ts b/payego_ui/src/api/auth.ts index b0a5f96..294b666 100644 --- a/payego_ui/src/api/auth.ts +++ b/payego_ui/src/api/auth.ts @@ -9,4 +9,6 @@ export const authApi = { resetPassword: (email: string, token: string, newPassword: string) => client.post('/api/auth/reset_password', { email, token, new_password: newPassword }), logout: () => client.post('/api/auth/logout', {}), getCurrentUser: () => client.get('/api/user/current').then(res => res.data), + verifyEmail: (token: string) => client.get(`/api/auth/verify-email?token=${token}`), + resendVerification: () => client.post('/api/auth/resend-verification', {}), }; diff --git a/payego_ui/src/components/RegisterForm.tsx b/payego_ui/src/components/RegisterForm.tsx index daab18d..6c9a788 100644 --- a/payego_ui/src/components/RegisterForm.tsx +++ b/payego_ui/src/components/RegisterForm.tsx @@ -23,6 +23,7 @@ type RegisterFormValues = z.infer; const RegisterForm: React.FC = () => { const { login } = useAuth(); const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); @@ -74,15 +75,40 @@ const RegisterForm: React.FC = () => { } }; + const handleResend = async () => { + try { + await authApi.resendVerification(); + setSuccessMsg("Verification email resent!"); + } catch (err: any) { + setError(getErrorMessage(err)); + } + }; + if (showVerification) { return (
-

Verify Email

-

Verification is placeholder for now, you are already logged in.

- +
+ + + +
+

Check Your Email

+

+ We've sent a verification link to your email address. Please click the link to secure your account. +

+ + {successMsg &&
{successMsg}
} + {error &&
{error}
} + +
+ + +
); @@ -147,7 +173,7 @@ const RegisterForm: React.FC = () => { {password && (
-
+
Strength: {strength.label}
diff --git a/payego_ui/src/pages/VerifyEmail.tsx b/payego_ui/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..f08b1d9 --- /dev/null +++ b/payego_ui/src/pages/VerifyEmail.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { authApi } from '../api/auth'; +import { getErrorMessage } from '../utils/errorHandler'; + +const VerifyEmail: React.FC = () => { + const [searchParams] = useSearchParams(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + useEffect(() => { + const verify = async () => { + if (!token) { + setStatus('error'); + setError('Missing verification token'); + return; + } + + try { + await authApi.verifyEmail(token); + setStatus('success'); + setTimeout(() => navigate('/dashboard'), 3000); + } catch (err: any) { + setStatus('error'); + setError(getErrorMessage(err)); + } + }; + + verify(); + }, [token, navigate]); + + return ( +
+
+
+ {status === 'loading' && ( + <> +
+

Verifying Email...

+

Please wait while we confirm your email address.

+ + )} + + {status === 'success' && ( + <> +
+ + + +
+

Verification Successful!

+

+ Your email has been verified. You're being redirected to your dashboard. +

+ + + )} + + {status === 'error' && ( + <> +
+ + + +
+

Verification Failed

+
+ {error || 'Unknown error occurred'} +
+ + + )} +
+
+
+ ); +}; + +export default VerifyEmail;