Skip to main content

allowthem_core/
users.rs

1use chrono::Utc;
2
3use crate::db::Db;
4use crate::error::AuthError;
5use crate::password::hash_password;
6use crate::types::{Email, User, UserId, Username};
7
8/// Map a SQLite UNIQUE constraint violation to `AuthError::Conflict`.
9///
10/// SQLite UNIQUE violations include the constraint name in the message,
11/// e.g. "UNIQUE constraint failed: allowthem_users.email".
12pub(crate) fn map_unique_violation(err: sqlx::Error) -> AuthError {
13    if let sqlx::Error::Database(ref db_err) = err {
14        let msg = db_err.message();
15        if msg.contains("UNIQUE constraint failed") {
16            if msg.contains("email") {
17                return AuthError::Conflict("email already exists".into());
18            }
19            if msg.contains("username") {
20                return AuthError::Conflict("username already exists".into());
21            }
22            return AuthError::Conflict(msg.to_string());
23        }
24    }
25    AuthError::Database(err)
26}
27
28impl Db {
29    /// Create a user with email, plaintext password, and optional username.
30    ///
31    /// Hashes the password with Argon2id (via `password::hash_password`).
32    /// Returns the created User (without password_hash in the returned struct).
33    pub async fn create_user(
34        &self,
35        email: Email,
36        password: &str,
37        username: Option<Username>,
38    ) -> Result<User, AuthError> {
39        let id = UserId::new();
40        let pw_hash = hash_password(password)?;
41        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
42
43        sqlx::query(
44            "INSERT INTO allowthem_users \
45             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at) \
46             VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5)",
47        )
48        .bind(id)
49        .bind(&email)
50        .bind(&username)
51        .bind(&pw_hash)
52        .bind(&now)
53        .execute(self.pool())
54        .await
55        .map_err(map_unique_violation)?;
56
57        self.get_user(id).await
58    }
59
60    /// Look up a user by ID. Returns User with password_hash = None.
61    pub async fn get_user(&self, id: UserId) -> Result<User, AuthError> {
62        sqlx::query_as::<_, User>(
63            "SELECT id, email, username, NULL as password_hash, \
64             email_verified, is_active, created_at, updated_at \
65             FROM allowthem_users WHERE id = ?",
66        )
67        .bind(id)
68        .fetch_optional(self.pool())
69        .await?
70        .ok_or(AuthError::NotFound)
71    }
72
73    /// Look up a user by email. Returns User with password_hash = None.
74    pub async fn get_user_by_email(&self, email: &Email) -> Result<User, AuthError> {
75        sqlx::query_as::<_, User>(
76            "SELECT id, email, username, NULL as password_hash, \
77             email_verified, is_active, created_at, updated_at \
78             FROM allowthem_users WHERE email = ?",
79        )
80        .bind(email)
81        .fetch_optional(self.pool())
82        .await?
83        .ok_or(AuthError::NotFound)
84    }
85
86    /// Look up a user by username. Returns User with password_hash = None.
87    pub async fn get_user_by_username(&self, username: &Username) -> Result<User, AuthError> {
88        sqlx::query_as::<_, User>(
89            "SELECT id, email, username, NULL as password_hash, \
90             email_verified, is_active, created_at, updated_at \
91             FROM allowthem_users WHERE username = ?",
92        )
93        .bind(username)
94        .fetch_optional(self.pool())
95        .await?
96        .ok_or(AuthError::NotFound)
97    }
98
99    /// Look up a user by email OR username for login.
100    ///
101    /// Returns User WITH password_hash populated. The caller is responsible
102    /// for calling `verify_password()` to check the password.
103    pub async fn find_for_login(&self, identifier: &str) -> Result<User, AuthError> {
104        sqlx::query_as::<_, User>(
105            "SELECT id, email, username, password_hash, \
106             email_verified, is_active, created_at, updated_at \
107             FROM allowthem_users WHERE email = ?1 OR username = ?1",
108        )
109        .bind(identifier)
110        .fetch_optional(self.pool())
111        .await?
112        .ok_or(AuthError::NotFound)
113    }
114
115    /// Update a user's email. Also updates updated_at.
116    pub async fn update_user_email(&self, id: UserId, email: Email) -> Result<(), AuthError> {
117        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
118        let result =
119            sqlx::query("UPDATE allowthem_users SET email = ?1, updated_at = ?2 WHERE id = ?3")
120                .bind(&email)
121                .bind(&now)
122                .bind(id)
123                .execute(self.pool())
124                .await
125                .map_err(map_unique_violation)?;
126
127        if result.rows_affected() == 0 {
128            return Err(AuthError::NotFound);
129        }
130        Ok(())
131    }
132
133    /// Update a user's username (set or clear). Also updates updated_at.
134    pub async fn update_user_username(
135        &self,
136        id: UserId,
137        username: Option<Username>,
138    ) -> Result<(), AuthError> {
139        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
140        let result =
141            sqlx::query("UPDATE allowthem_users SET username = ?1, updated_at = ?2 WHERE id = ?3")
142                .bind(&username)
143                .bind(&now)
144                .bind(id)
145                .execute(self.pool())
146                .await
147                .map_err(map_unique_violation)?;
148
149        if result.rows_affected() == 0 {
150            return Err(AuthError::NotFound);
151        }
152        Ok(())
153    }
154
155    /// Update a user's is_active flag. Also updates updated_at.
156    pub async fn update_user_active(&self, id: UserId, is_active: bool) -> Result<(), AuthError> {
157        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
158        let result =
159            sqlx::query("UPDATE allowthem_users SET is_active = ?1, updated_at = ?2 WHERE id = ?3")
160                .bind(is_active)
161                .bind(&now)
162                .bind(id)
163                .execute(self.pool())
164                .await?;
165
166        if result.rows_affected() == 0 {
167            return Err(AuthError::NotFound);
168        }
169        Ok(())
170    }
171
172    /// Delete a user by ID. Cascades to sessions, user_roles, user_permissions.
173    pub async fn delete_user(&self, id: UserId) -> Result<(), AuthError> {
174        let result = sqlx::query("DELETE FROM allowthem_users WHERE id = ?")
175            .bind(id)
176            .execute(self.pool())
177            .await?;
178
179        if result.rows_affected() == 0 {
180            return Err(AuthError::NotFound);
181        }
182        Ok(())
183    }
184
185    /// List all users ordered by `created_at ASC`. Returns User with `password_hash = None`.
186    pub async fn list_users(&self) -> Result<Vec<User>, AuthError> {
187        sqlx::query_as::<_, User>(
188            "SELECT id, email, username, NULL as password_hash, \
189             email_verified, is_active, created_at, updated_at \
190             FROM allowthem_users ORDER BY created_at ASC",
191        )
192        .fetch_all(self.pool())
193        .await
194        .map_err(AuthError::Database)
195    }
196
197    /// Update a user's password. Hashes `new_password` with Argon2id and stores it.
198    ///
199    /// Returns `AuthError::NotFound` if no user with `id` exists.
200    pub async fn update_user_password(
201        &self,
202        id: UserId,
203        new_password: &str,
204    ) -> Result<(), AuthError> {
205        let pw_hash = hash_password(new_password)?;
206        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
207        let result = sqlx::query(
208            "UPDATE allowthem_users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3",
209        )
210        .bind(&pw_hash)
211        .bind(&now)
212        .bind(id)
213        .execute(self.pool())
214        .await?;
215
216        if result.rows_affected() == 0 {
217            return Err(AuthError::NotFound);
218        }
219        Ok(())
220    }
221}