feat: admin role
This commit is contained in:
1
crates/adapters/sqlite/migrations/0007_user_role.sql
Normal file
1
crates/adapters/sqlite/migrations/0007_user_role.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'standard';
|
||||
@@ -5,7 +5,7 @@ use sqlx::SqlitePool;
|
||||
use super::models::UserSummaryRow;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::User,
|
||||
models::{User, UserRole},
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
@@ -24,11 +24,19 @@ impl SqliteUserRepository {
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> UserRole {
|
||||
match s {
|
||||
"admin" => UserRole::Admin,
|
||||
_ => UserRole::Standard,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_user(
|
||||
id_str: String,
|
||||
email_str: String,
|
||||
username_str: String,
|
||||
hash_str: String,
|
||||
role: UserRole,
|
||||
) -> Result<User, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
@@ -43,6 +51,7 @@ impl SqliteUserRepository {
|
||||
email,
|
||||
username,
|
||||
hash,
|
||||
role,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -52,7 +61,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.value();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
||||
email_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -65,6 +74,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.email,
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -73,7 +83,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let username_str = username.value();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
||||
username_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -86,6 +96,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.email,
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -111,9 +122,13 @@ impl UserRepository for SqliteUserRepository {
|
||||
let hash = user.password_hash().value();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
let role = match user.role() {
|
||||
UserRole::Admin => "admin",
|
||||
UserRole::Standard => "standard",
|
||||
};
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
id, email, username, hash, created_at
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, email, username, hash, created_at, role
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -125,7 +140,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -138,6 +153,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.email,
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -172,7 +188,7 @@ mod tests {
|
||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)"
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard')"
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::models::ExportFormat;
|
||||
use domain::models::{ExportFormat, UserRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct LogReviewCommand {
|
||||
@@ -30,6 +30,7 @@ pub struct RegisterCommand {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
|
||||
@@ -41,6 +41,6 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
|
||||
|
||||
let hash = ctx.password_hasher.hash(&cmd.password).await?;
|
||||
ctx.user_repository
|
||||
.save(&User::new(email, username, hash))
|
||||
.save(&User::new(email, username, hash, cmd.role))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::default;
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
@@ -254,21 +256,35 @@ impl ReviewHistory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum UserRole {
|
||||
#[default]
|
||||
Standard,
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
id: UserId,
|
||||
email: Email,
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
role: UserRole,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
|
||||
pub fn new(
|
||||
email: Email,
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
role: UserRole,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,12 +293,14 @@ impl User {
|
||||
email: Email,
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
role: UserRole,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +320,9 @@ impl User {
|
||||
pub fn password_hash(&self) -> &PasswordHash {
|
||||
&self.password_hash
|
||||
}
|
||||
pub fn role(&self) -> &UserRole {
|
||||
&self.role
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{header, header::AUTHORIZATION, request::Parts},
|
||||
http::{StatusCode, header, header::AUTHORIZATION, request::Parts},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
@@ -91,6 +91,33 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdminUser(pub UserId);
|
||||
|
||||
impl<S> FromRequestParts<S> for AdminUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = axum::response::Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let RequiredCookieUser(user_id) =
|
||||
RequiredCookieUser::from_request_parts(parts, state).await?;
|
||||
let user = app_state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
match user.role() {
|
||||
domain::models::UserRole::Admin => Ok(AdminUser(user_id)),
|
||||
_ => Err(StatusCode::FORBIDDEN.into_response()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -203,6 +203,7 @@ pub mod html {
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1181,6 +1182,7 @@ pub mod api {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user