Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ APP_URL=http://localhost:8080
FEE_BPS=100
EXCHANGE_API_URL=https://api.exchangerate-api.com/v4/latest
DEFAULT_COUNTRY=Nigeria

# SMTP Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASS=your_app_password
SMTP_FROM=noreply@payego.com
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions bin/payego/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ fake = { version = "2.9", features = ["derive"] }
validator.workspace = true
argon2.workspace = true
reqwest = { version = "0.12", features = ["json", "blocking"] }
sha2 = { workspace = true }
hex = { workspace = true }
154 changes: 154 additions & 0 deletions bin/payego/tests/verification_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
mod common;

use axum::http::StatusCode;
use axum_test::TestServer;
use common::{create_test_app, create_test_app_state};
use diesel::prelude::*;
use payego_primitives::schema::verification_tokens::dsl::*;
use serde_json::json;
use serial_test::serial;

#[tokio::test]
#[serial]
async fn test_full_email_verification_flow() {
let state = create_test_app_state();
let app = create_test_app(state.clone());
let server = TestServer::new(app).unwrap();

let email_str = format!("verify_{}@example.com", uuid::Uuid::new_v4());

// 1. Register user
let res = server
.post("/api/auth/register")
.json(&json!({
"email": email_str,
"password": "SecurePass123!",
"username": Some(format!("user_{}", uuid::Uuid::new_v4()))
}))
.await;
res.assert_status(StatusCode::CREATED);

// 2. Check user status (should be unverified)
let login_res = server
.post("/api/auth/login")
.json(&json!({
"email": email_str,
"password": "SecurePass123!"
}))
.await;
login_res.assert_status(StatusCode::OK);
let login_body: serde_json::Value = login_res.json();
let token = login_body["token"].as_str().unwrap();

// Get user info to check verification
let user_res = server
.get("/api/user/current")
.add_header(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", token),
)
.await;
user_res.assert_status(StatusCode::OK);
let user_body: serde_json::Value = user_res.json();
assert!(user_body["email_verified_at"].is_null());

// 3. Manually create a known verification token for testing
let raw_test_token = "test-token-uuid-12345";
let hashed_test_token =
payego_core::services::auth_service::verification::VerificationService::hash_token(
raw_test_token,
);

let mut conn = state.db.get().unwrap();

// Find the user ID we just created
use payego_primitives::schema::users::dsl::*;
let user_id_val: uuid::Uuid = users
.filter(email.eq(email_str))
.select(id)
.first(&mut conn)
.expect("User should exist");

// Insert our known token
use payego_core::repositories::verification_repository::VerificationRepository;
use payego_primitives::models::entities::verification_token::NewVerificationToken;

VerificationRepository::delete_for_user(&mut conn, user_id_val).unwrap();
VerificationRepository::create(
&mut conn,
NewVerificationToken {
user_id: user_id_val,
token_hash: hashed_test_token,
expires_at: chrono::Utc::now().naive_utc() + chrono::Duration::hours(1),
},
)
.unwrap();

// 4. Verify email using the RAW token
let verify_res = server
.get(&format!("/api/auth/verify-email?token={}", raw_test_token))
.await;
verify_res.assert_status(StatusCode::OK);

// 5. Check user status again (should be verified)
let user_res_after = server
.get("/api/user/current")
.add_header(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", token),
)
.await;
user_res_after.assert_status(StatusCode::OK);
let user_body_after: serde_json::Value = user_res_after.json();
println!("DBG: User body after verification: {:#?}", user_body_after);
assert!(!user_body_after["email_verified_at"].is_null());
}

#[tokio::test]
#[serial]
async fn test_resend_verification_email() {
let state = create_test_app_state();
let app = create_test_app(state.clone());
let server = TestServer::new(app).unwrap();

let email_str = format!("resend_{}@example.com", uuid::Uuid::new_v4());

// Register
server
.post("/api/auth/register")
.json(&json!({
"email": email_str,
"password": "SecurePass123!",
"username": Some(format!("user_{}", uuid::Uuid::new_v4()))
}))
.await
.assert_status(StatusCode::CREATED);

// Login to get token
let login_res = server
.post("/api/auth/login")
.json(&json!({
"email": email_str,
"password": "SecurePass123!"
}))
.await;
let auth_token = login_res.json::<serde_json::Value>()["token"]
.as_str()
.unwrap()
.to_string();

// Resend
let resend_res = server
.post("/api/auth/resend-verification")
.add_header(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", auth_token),
)
.await;
resend_res.assert_status(StatusCode::OK);

// Verify a new token was created
let mut conn = state.db.get().unwrap();
let count: i64 = verification_tokens.count().get_result(&mut conn).unwrap();
assert!(count >= 1);
}
70 changes: 63 additions & 7 deletions crates/core/src/clients/email.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use payego_primitives::error::ApiError;
// use lettre::{SmtpTransport, Transport, Message};
// use secrecy::ExposeSecret;
use std::env;

#[derive(Clone)]
pub struct EmailClient {
// transport: SmtpTransport,
transport: Option<SmtpTransport>,
from_email: String,
}

impl Default for EmailClient {
Expand All @@ -15,12 +17,66 @@ impl Default for EmailClient {

impl EmailClient {
pub fn new() -> Self {
Self {}
let smtp_host = env::var("SMTP_HOST").ok();
let smtp_port = env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(587);
let smtp_user = env::var("SMTP_USER").ok();
let smtp_pass = env::var("SMTP_PASS").ok();
let from_email = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@payego.com".to_string());

let transport =
if let (Some(host), Some(user), Some(pass)) = (smtp_host, smtp_user, smtp_pass) {
let creds = Credentials::new(user, pass);
Some(
SmtpTransport::relay(&host)
.unwrap()
.credentials(creds)
.port(smtp_port)
.build(),
)
} else {
tracing::warn!("SMTP configuration missing, email client running in mock mode");
None
};

Self {
transport,
from_email,
}
}

pub async fn send_email(&self, _to: &str, _subject: &str, _body: &str) -> Result<(), ApiError> {
// Placeholder for real email sending logic
tracing::info!("Sending email to: {}, subject: {}", _to, _subject);
pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), ApiError> {
if let Some(ref transport) = self.transport {
let email = Message::builder()
.from(
self.from_email
.parse()
.map_err(|e| ApiError::Internal(format!("Invalid from email: {}", e)))?,
)
.to(to
.parse()
.map_err(|e| ApiError::Internal(format!("Invalid recipient email: {}", e)))?)
.subject(subject)
.body(body.to_string())
.map_err(|e| ApiError::Internal(format!("Failed to build email: {}", e)))?;

transport.send(&email).map_err(|e| {
tracing::error!("Failed to send email: {}", e);
ApiError::Internal("Failed to send email".to_string())
})?;

tracing::info!("Email sent successfully to: {}", to);
} else {
tracing::info!(
"[MOCK EMAIL] To: {}, Subject: {}, Body: {}",
to,
subject,
body
);
}

Ok(())
}
}
1 change: 1 addition & 0 deletions crates/core/src/services/auth_service/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ impl UserService {
username: user_data.username,
wallets: walletz,
created_at: user_data.created_at,
email_verified_at: user_data.email_verified_at,
})
}
}
2 changes: 1 addition & 1 deletion crates/core/src/services/auth_service/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ impl VerificationService {
Ok(())
}

fn hash_token(token: &str) -> String {
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
Expand Down
1 change: 1 addition & 0 deletions crates/primitives/src/models/dtos/auth_dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub struct CurrentUserResponse {
pub username: Option<String>,
pub wallets: Vec<WalletSummaryDto>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub email_verified_at: Option<chrono::DateTime<chrono::Utc>>,
}

// --- Health ---
Expand Down
2 changes: 2 additions & 0 deletions payego_ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Transactions from './components/Transactions';
import Profile from './components/Profile';
import Sidebar from './components/Sidebar';
import VerifyEmail from './pages/VerifyEmail';
import Security from './pages/Security';
import { useState } from 'react';
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeToggle from './components/ThemeToggle';
Expand Down Expand Up @@ -117,6 +118,7 @@ function App() {
<Route path="/wallets" element={<ProtectedRoute><Wallets /></ProtectedRoute>} />
<Route path="/transactions" element={<ProtectedRoute><Transactions /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/security" element={<ProtectedRoute><Security /></ProtectedRoute>} />
<Route path="/success" element={<SuccessPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
Expand Down
1 change: 1 addition & 0 deletions payego_ui/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const authApi = {
getCurrentUser: () => client.get<User>('/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', {}),
getAuditLogs: (page: number = 1, size: number = 20) => client.get(`/api/user/audit-logs?page=${page}&size=${size}`),
};
22 changes: 22 additions & 0 deletions payego_ui/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ const Dashboard: React.FC = () => {
</div>
)}

{user && !user.email_verified_at && !isLoading && (
<div className="mb-8 p-4 bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-200 rounded-2xl shadow-sm animate-pulse-subtle">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-yellow-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">📧</span>
</div>
<div>
<h4 className="font-black text-yellow-900 leading-tight">Action Required</h4>
<p className="text-sm text-yellow-700">Please verify your email to unlock full account features.</p>
</div>
</div>
<Link
to="/security"
className="px-6 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-sm font-black rounded-xl transition-all shadow-lg hover:shadow-yellow-600/20"
>
Verify Now
</Link>
</div>
</div>
)}

{user && !isLoading && (
<div className="space-y-4">
{/* Stats */}
Expand Down
1 change: 1 addition & 0 deletions payego_ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, setIsOpen }) => {
{ to: "/banks", label: "Banks", icon: "🏛️" },
{ to: "/wallets", label: "Wallets", icon: "💳" },
{ to: "/transactions", label: "Transactions", icon: "📜" },
{ to: "/security", label: "Security", icon: "🛡️" },
{ to: "/profile", label: "Profile", icon: "👤" },
];

Expand Down
Loading