use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub api_token: Option<String>,
pub is_verified: bool,
pub is_admin: bool,
pub two_factor_enabled: bool,
#[serde(skip_serializing)]
pub two_factor_secret: Option<String>, #[serde(skip_serializing)]
pub two_factor_backup_codes: Option<Vec<String>>, pub two_factor_verified_at: Option<DateTime<Utc>>,
#[serde(default = "default_true")]
pub email_notifications: bool,
#[serde(default = "default_true")]
pub security_alerts: bool,
#[serde(default)]
pub preferences: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_true() -> bool {
true
}
impl User {
pub fn placeholder(id: Uuid) -> Self {
let now = Utc::now();
Self {
id,
username: "Unknown".to_string(),
email: String::new(),
password_hash: String::new(),
api_token: None,
is_verified: false,
is_admin: false,
two_factor_enabled: false,
two_factor_secret: None,
two_factor_backup_codes: None,
two_factor_verified_at: None,
email_notifications: true,
security_alerts: true,
preferences: serde_json::Value::Object(Default::default()),
created_at: now,
updated_at: now,
}
}
}
#[cfg(feature = "postgres")]
impl User {
pub async fn find_by_email(pool: &sqlx::PgPool, email: &str) -> sqlx::Result<Option<Self>> {
sqlx::query_as::<_, Self>("SELECT * FROM users WHERE email = $1")
.bind(email)
.fetch_optional(pool)
.await
}
pub async fn find_by_username(
pool: &sqlx::PgPool,
username: &str,
) -> sqlx::Result<Option<Self>> {
sqlx::query_as::<_, Self>("SELECT * FROM users WHERE username = $1")
.bind(username)
.fetch_optional(pool)
.await
}
pub async fn find_by_id(pool: &sqlx::PgPool, id: Uuid) -> sqlx::Result<Option<Self>> {
sqlx::query_as::<_, Self>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn find_by_ids(pool: &sqlx::PgPool, ids: &[Uuid]) -> sqlx::Result<Vec<Self>> {
if ids.is_empty() {
return Ok(Vec::new());
}
sqlx::query_as::<_, Self>("SELECT * FROM users WHERE id = ANY($1)")
.bind(ids)
.fetch_all(pool)
.await
}
pub async fn create(
pool: &sqlx::PgPool,
username: &str,
email: &str,
password_hash: &str,
) -> sqlx::Result<Self> {
sqlx::query_as::<_, Self>(
r#"
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING *
"#,
)
.bind(username)
.bind(email)
.bind(password_hash)
.fetch_one(pool)
.await
}
pub async fn set_api_token(
pool: &sqlx::PgPool,
user_id: Uuid,
token: &str,
) -> sqlx::Result<()> {
sqlx::query("UPDATE users SET api_token = $1 WHERE id = $2")
.bind(token)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn enable_2fa(
pool: &sqlx::PgPool,
user_id: Uuid,
secret: &str,
backup_codes: &[String], ) -> sqlx::Result<()> {
sqlx::query(
r#"
UPDATE users
SET two_factor_enabled = TRUE,
two_factor_secret = $1,
two_factor_backup_codes = $2,
two_factor_verified_at = NOW(),
updated_at = NOW()
WHERE id = $3
"#,
)
.bind(secret)
.bind(backup_codes)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn disable_2fa(pool: &sqlx::PgPool, user_id: Uuid) -> sqlx::Result<()> {
sqlx::query(
r#"
UPDATE users
SET two_factor_enabled = FALSE,
two_factor_secret = NULL,
two_factor_backup_codes = NULL,
two_factor_verified_at = NULL,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_2fa_verified(pool: &sqlx::PgPool, user_id: Uuid) -> sqlx::Result<()> {
sqlx::query(
"UPDATE users SET two_factor_verified_at = NOW(), updated_at = NOW() WHERE id = $1",
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn remove_backup_code(
pool: &sqlx::PgPool,
user_id: Uuid,
code_index: usize,
) -> sqlx::Result<()> {
let user =
Self::find_by_id(pool, user_id).await?.ok_or_else(|| sqlx::Error::RowNotFound)?;
if let Some(mut codes) = user.two_factor_backup_codes {
if code_index < codes.len() {
codes.remove(code_index);
sqlx::query(
"UPDATE users SET two_factor_backup_codes = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&codes)
.bind(user_id)
.execute(pool)
.await?;
}
}
Ok(())
}
}