Skip to content

Commit 1eb7fc1

Browse files
committed
project ready for version 1
1 parent 53e4082 commit 1eb7fc1

16 files changed

Lines changed: 491 additions & 10 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,10 @@ APP_URL=http://localhost:8080
4040
FEE_BPS=100
4141
EXCHANGE_API_URL=https://api.exchangerate-api.com/v4/latest
4242
DEFAULT_COUNTRY=Nigeria
43+
44+
# SMTP Configuration
45+
SMTP_HOST=smtp.gmail.com
46+
SMTP_PORT=587
47+
SMTP_USER=your_email@gmail.com
48+
SMTP_PASS=your_app_password
49+
SMTP_FROM=noreply@payego.com

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/payego/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ fake = { version = "2.9", features = ["derive"] }
3232
validator.workspace = true
3333
argon2.workspace = true
3434
reqwest = { version = "0.12", features = ["json", "blocking"] }
35+
sha2 = { workspace = true }
36+
hex = { workspace = true }
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
mod common;
2+
3+
use axum::http::StatusCode;
4+
use axum_test::TestServer;
5+
use common::{create_test_app, create_test_app_state};
6+
use diesel::prelude::*;
7+
use payego_primitives::schema::verification_tokens::dsl::*;
8+
use serde_json::json;
9+
use serial_test::serial;
10+
11+
#[tokio::test]
12+
#[serial]
13+
async fn test_full_email_verification_flow() {
14+
let state = create_test_app_state();
15+
let app = create_test_app(state.clone());
16+
let server = TestServer::new(app).unwrap();
17+
18+
let email_str = format!("verify_{}@example.com", uuid::Uuid::new_v4());
19+
20+
// 1. Register user
21+
let res = server
22+
.post("/api/auth/register")
23+
.json(&json!({
24+
"email": email_str,
25+
"password": "SecurePass123!",
26+
"username": Some(format!("user_{}", uuid::Uuid::new_v4()))
27+
}))
28+
.await;
29+
res.assert_status(StatusCode::CREATED);
30+
31+
// 2. Check user status (should be unverified)
32+
let login_res = server
33+
.post("/api/auth/login")
34+
.json(&json!({
35+
"email": email_str,
36+
"password": "SecurePass123!"
37+
}))
38+
.await;
39+
login_res.assert_status(StatusCode::OK);
40+
let login_body: serde_json::Value = login_res.json();
41+
let token = login_body["token"].as_str().unwrap();
42+
43+
// Get user info to check verification
44+
let user_res = server
45+
.get("/api/user/current")
46+
.add_header(
47+
axum::http::header::AUTHORIZATION,
48+
format!("Bearer {}", token),
49+
)
50+
.await;
51+
user_res.assert_status(StatusCode::OK);
52+
let user_body: serde_json::Value = user_res.json();
53+
assert!(user_body["email_verified_at"].is_null());
54+
55+
// 3. Manually create a known verification token for testing
56+
let raw_test_token = "test-token-uuid-12345";
57+
let hashed_test_token =
58+
payego_core::services::auth_service::verification::VerificationService::hash_token(
59+
raw_test_token,
60+
);
61+
62+
let mut conn = state.db.get().unwrap();
63+
64+
// Find the user ID we just created
65+
use payego_primitives::schema::users::dsl::*;
66+
let user_id_val: uuid::Uuid = users
67+
.filter(email.eq(email_str))
68+
.select(id)
69+
.first(&mut conn)
70+
.expect("User should exist");
71+
72+
// Insert our known token
73+
use payego_core::repositories::verification_repository::VerificationRepository;
74+
use payego_primitives::models::entities::verification_token::NewVerificationToken;
75+
76+
VerificationRepository::delete_for_user(&mut conn, user_id_val).unwrap();
77+
VerificationRepository::create(
78+
&mut conn,
79+
NewVerificationToken {
80+
user_id: user_id_val,
81+
token_hash: hashed_test_token,
82+
expires_at: chrono::Utc::now().naive_utc() + chrono::Duration::hours(1),
83+
},
84+
)
85+
.unwrap();
86+
87+
// 4. Verify email using the RAW token
88+
let verify_res = server
89+
.get(&format!("/api/auth/verify-email?token={}", raw_test_token))
90+
.await;
91+
verify_res.assert_status(StatusCode::OK);
92+
93+
// 5. Check user status again (should be verified)
94+
let user_res_after = server
95+
.get("/api/user/current")
96+
.add_header(
97+
axum::http::header::AUTHORIZATION,
98+
format!("Bearer {}", token),
99+
)
100+
.await;
101+
user_res_after.assert_status(StatusCode::OK);
102+
let user_body_after: serde_json::Value = user_res_after.json();
103+
println!("DBG: User body after verification: {:#?}", user_body_after);
104+
assert!(!user_body_after["email_verified_at"].is_null());
105+
}
106+
107+
#[tokio::test]
108+
#[serial]
109+
async fn test_resend_verification_email() {
110+
let state = create_test_app_state();
111+
let app = create_test_app(state.clone());
112+
let server = TestServer::new(app).unwrap();
113+
114+
let email_str = format!("resend_{}@example.com", uuid::Uuid::new_v4());
115+
116+
// Register
117+
server
118+
.post("/api/auth/register")
119+
.json(&json!({
120+
"email": email_str,
121+
"password": "SecurePass123!",
122+
"username": Some(format!("user_{}", uuid::Uuid::new_v4()))
123+
}))
124+
.await
125+
.assert_status(StatusCode::CREATED);
126+
127+
// Login to get token
128+
let login_res = server
129+
.post("/api/auth/login")
130+
.json(&json!({
131+
"email": email_str,
132+
"password": "SecurePass123!"
133+
}))
134+
.await;
135+
let auth_token = login_res.json::<serde_json::Value>()["token"]
136+
.as_str()
137+
.unwrap()
138+
.to_string();
139+
140+
// Resend
141+
let resend_res = server
142+
.post("/api/auth/resend-verification")
143+
.add_header(
144+
axum::http::header::AUTHORIZATION,
145+
format!("Bearer {}", auth_token),
146+
)
147+
.await;
148+
resend_res.assert_status(StatusCode::OK);
149+
150+
// Verify a new token was created
151+
let mut conn = state.db.get().unwrap();
152+
let count: i64 = verification_tokens.count().get_result(&mut conn).unwrap();
153+
assert!(count >= 1);
154+
}

crates/core/src/clients/email.rs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
use lettre::transport::smtp::authentication::Credentials;
2+
use lettre::{Message, SmtpTransport, Transport};
13
use payego_primitives::error::ApiError;
2-
// use lettre::{SmtpTransport, Transport, Message};
3-
// use secrecy::ExposeSecret;
4+
use std::env;
45

56
#[derive(Clone)]
67
pub struct EmailClient {
7-
// transport: SmtpTransport,
8+
transport: Option<SmtpTransport>,
9+
from_email: String,
810
}
911

1012
impl Default for EmailClient {
@@ -15,12 +17,66 @@ impl Default for EmailClient {
1517

1618
impl EmailClient {
1719
pub fn new() -> Self {
18-
Self {}
20+
let smtp_host = env::var("SMTP_HOST").ok();
21+
let smtp_port = env::var("SMTP_PORT")
22+
.ok()
23+
.and_then(|p| p.parse::<u16>().ok())
24+
.unwrap_or(587);
25+
let smtp_user = env::var("SMTP_USER").ok();
26+
let smtp_pass = env::var("SMTP_PASS").ok();
27+
let from_email = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@payego.com".to_string());
28+
29+
let transport =
30+
if let (Some(host), Some(user), Some(pass)) = (smtp_host, smtp_user, smtp_pass) {
31+
let creds = Credentials::new(user, pass);
32+
Some(
33+
SmtpTransport::relay(&host)
34+
.unwrap()
35+
.credentials(creds)
36+
.port(smtp_port)
37+
.build(),
38+
)
39+
} else {
40+
tracing::warn!("SMTP configuration missing, email client running in mock mode");
41+
None
42+
};
43+
44+
Self {
45+
transport,
46+
from_email,
47+
}
1948
}
2049

21-
pub async fn send_email(&self, _to: &str, _subject: &str, _body: &str) -> Result<(), ApiError> {
22-
// Placeholder for real email sending logic
23-
tracing::info!("Sending email to: {}, subject: {}", _to, _subject);
50+
pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), ApiError> {
51+
if let Some(ref transport) = self.transport {
52+
let email = Message::builder()
53+
.from(
54+
self.from_email
55+
.parse()
56+
.map_err(|e| ApiError::Internal(format!("Invalid from email: {}", e)))?,
57+
)
58+
.to(to
59+
.parse()
60+
.map_err(|e| ApiError::Internal(format!("Invalid recipient email: {}", e)))?)
61+
.subject(subject)
62+
.body(body.to_string())
63+
.map_err(|e| ApiError::Internal(format!("Failed to build email: {}", e)))?;
64+
65+
transport.send(&email).map_err(|e| {
66+
tracing::error!("Failed to send email: {}", e);
67+
ApiError::Internal("Failed to send email".to_string())
68+
})?;
69+
70+
tracing::info!("Email sent successfully to: {}", to);
71+
} else {
72+
tracing::info!(
73+
"[MOCK EMAIL] To: {}, Subject: {}, Body: {}",
74+
to,
75+
subject,
76+
body
77+
);
78+
}
79+
2480
Ok(())
2581
}
2682
}

crates/core/src/services/auth_service/user.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ impl UserService {
4242
username: user_data.username,
4343
wallets: walletz,
4444
created_at: user_data.created_at,
45+
email_verified_at: user_data.email_verified_at,
4546
})
4647
}
4748
}

crates/core/src/services/auth_service/verification.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl VerificationService {
5757
Ok(())
5858
}
5959

60-
fn hash_token(token: &str) -> String {
60+
pub fn hash_token(token: &str) -> String {
6161
let mut hasher = Sha256::new();
6262
hasher.update(token.as_bytes());
6363
hex::encode(hasher.finalize())

crates/primitives/src/models/dtos/auth_dto.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ pub struct CurrentUserResponse {
104104
pub username: Option<String>,
105105
pub wallets: Vec<WalletSummaryDto>,
106106
pub created_at: chrono::DateTime<chrono::Utc>,
107+
pub email_verified_at: Option<chrono::DateTime<chrono::Utc>>,
107108
}
108109

109110
// --- Health ---

payego_ui/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Transactions from './components/Transactions';
1717
import Profile from './components/Profile';
1818
import Sidebar from './components/Sidebar';
1919
import VerifyEmail from './pages/VerifyEmail';
20+
import Security from './pages/Security';
2021
import { useState } from 'react';
2122
import { ThemeProvider } from './contexts/ThemeContext';
2223
import ThemeToggle from './components/ThemeToggle';
@@ -117,6 +118,7 @@ function App() {
117118
<Route path="/wallets" element={<ProtectedRoute><Wallets /></ProtectedRoute>} />
118119
<Route path="/transactions" element={<ProtectedRoute><Transactions /></ProtectedRoute>} />
119120
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
121+
<Route path="/security" element={<ProtectedRoute><Security /></ProtectedRoute>} />
120122
<Route path="/success" element={<SuccessPage />} />
121123
<Route path="/verify-email" element={<VerifyEmail />} />
122124
</Routes>

payego_ui/src/api/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export const authApi = {
1111
getCurrentUser: () => client.get<User>('/api/user/current').then(res => res.data),
1212
verifyEmail: (token: string) => client.get(`/api/auth/verify-email?token=${token}`),
1313
resendVerification: () => client.post('/api/auth/resend-verification', {}),
14+
getAuditLogs: (page: number = 1, size: number = 20) => client.get(`/api/user/audit-logs?page=${page}&size=${size}`),
1415
};

0 commit comments

Comments
 (0)