From 9be7af50d2013f139671ce68ef8877e037087990 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 May 2026 01:15:48 +0200 Subject: [PATCH] feat: admin role --- ...ba3b11c9f252311579f7daba335f70d78f07.json} | 6 +- ...b48027d22900a570b98a636c780cb3e2efd23.json | 20 ---- ...6389a0aca5a732531bb9ca9b99675d0a89f4.json} | 10 +- ...345b386fc7dc6cf3e72c924e2f5da49dfb469.json | 98 ----------------- ...2467a36d3920ff4c020f5461b2cdb04361888.json | 98 ----------------- ...73f78b29854518c48c2da3cc11a94d6d37bb1.json | 104 ------------------ ...83598865630241deffbb8b4175fa7a755099.json} | 10 +- ...99fe813b18b9e7d8d241462f0689969dc64f.json} | 10 +- Dockerfile | 4 +- .../sqlite/migrations/0007_user_role.sql | 1 + crates/adapters/sqlite/src/users.rs | 30 +++-- crates/application/src/commands.rs | 3 +- crates/application/src/use_cases/register.rs | 2 +- crates/domain/src/models/mod.rs | 23 +++- crates/presentation/src/extractors.rs | 29 ++++- crates/presentation/src/handlers.rs | 2 + 16 files changed, 109 insertions(+), 341 deletions(-) rename .sqlx/{query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json => query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json} (55%) delete mode 100644 .sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json rename .sqlx/{query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json => query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json} (67%) delete mode 100644 .sqlx/query-4ad3b7d450e46e8d4982a0517c7345b386fc7dc6cf3e72c924e2f5da49dfb469.json delete mode 100644 .sqlx/query-4c235060b78cc5c73e0400b39472467a36d3920ff4c020f5461b2cdb04361888.json delete mode 100644 .sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json rename .sqlx/{query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json => query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json} (67%) rename .sqlx/{query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json => query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json} (67%) create mode 100644 crates/adapters/sqlite/migrations/0007_user_role.sql diff --git a/.sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json b/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json similarity index 55% rename from .sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json rename to .sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json index af5b8a8..d60a26d 100644 --- a/.sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json +++ b/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + "query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [] }, - "hash": "21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b" + "hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07" } diff --git a/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json b/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json deleted file mode 100644 index f1f314f..0000000 --- a/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT COUNT(*) FROM reviews WHERE user_id = ?", - "describe": { - "columns": [ - { - "name": "COUNT(*)", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23" -} diff --git a/.sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json b/.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json similarity index 67% rename from .sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json rename to .sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json index 4f4fb4b..a562a73 100644 --- a/.sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json +++ b/.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash FROM users WHERE id = ?", + "query": "SELECT id, email, username, password_hash, role FROM users WHERE username = ?", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "password_hash", "ordinal": 3, "type_info": "Text" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" } ], "parameters": { @@ -31,8 +36,9 @@ true, false, false, + false, false ] }, - "hash": "e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36" + "hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4" } diff --git a/.sqlx/query-4ad3b7d450e46e8d4982a0517c7345b386fc7dc6cf3e72c924e2f5da49dfb469.json b/.sqlx/query-4ad3b7d450e46e8d4982a0517c7345b386fc7dc6cf3e72c924e2f5da49dfb469.json deleted file mode 100644 index 7b54061..0000000 --- a/.sqlx/query-4ad3b7d450e46e8d4982a0517c7345b386fc7dc6cf3e72c924e2f5da49dfb469.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.rating DESC, r.watched_at DESC\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "external_metadata_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "release_year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "director", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "poster_path", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "review_id", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "movie_id", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "rating", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "comment", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "watched_at", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "remote_actor_url", - "ordinal": 13, - "type_info": "Text" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false, - true, - false, - false, - true, - true, - false, - false, - false, - false, - true, - false, - false, - true - ] - }, - "hash": "4ad3b7d450e46e8d4982a0517c7345b386fc7dc6cf3e72c924e2f5da49dfb469" -} diff --git a/.sqlx/query-4c235060b78cc5c73e0400b39472467a36d3920ff4c020f5461b2cdb04361888.json b/.sqlx/query-4c235060b78cc5c73e0400b39472467a36d3920ff4c020f5461b2cdb04361888.json deleted file mode 100644 index a737e06..0000000 --- a/.sqlx/query-4c235060b78cc5c73e0400b39472467a36d3920ff4c020f5461b2cdb04361888.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "external_metadata_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "release_year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "director", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "poster_path", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "review_id", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "movie_id", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "rating", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "comment", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "watched_at", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "remote_actor_url", - "ordinal": 13, - "type_info": "Text" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false, - true, - false, - false, - true, - true, - false, - false, - false, - false, - true, - false, - false, - true - ] - }, - "hash": "4c235060b78cc5c73e0400b39472467a36d3920ff4c020f5461b2cdb04361888" -} diff --git a/.sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json b/.sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json deleted file mode 100644 index f0fefaa..0000000 --- a/.sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url,\n COALESCE(u.email, r.remote_actor_url) AS \"user_email!: String\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n LEFT JOIN users u ON u.id = r.user_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "external_metadata_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "release_year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "director", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "poster_path", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "review_id", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "movie_id", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "rating", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "comment", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "watched_at", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "remote_actor_url", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "user_email!: String", - "ordinal": 14, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - true, - false, - false, - true, - true, - false, - false, - false, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1" -} diff --git a/.sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json b/.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json similarity index 67% rename from .sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json rename to .sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json index e49a847..a0c31a5 100644 --- a/.sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json +++ b/.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash FROM users WHERE email = ?", + "query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "password_hash", "ordinal": 3, "type_info": "Text" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" } ], "parameters": { @@ -31,8 +36,9 @@ true, false, false, + false, false ] }, - "hash": "4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955" + "hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099" } diff --git a/.sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json b/.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json similarity index 67% rename from .sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json rename to .sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json index 8c06140..eadda08 100644 --- a/.sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json +++ b/.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash FROM users WHERE username = ?", + "query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "password_hash", "ordinal": 3, "type_info": "Text" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" } ], "parameters": { @@ -31,8 +36,9 @@ true, false, false, + false, false ] }, - "hash": "0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba" + "hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f" } diff --git a/Dockerfile b/Dockerfile index 04a391b..bc667c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,9 @@ RUN sqlite3 /build/dev.db \ sqlite3 /build/dev.db \ < crates/adapters/sqlite/migrations/0005_activitypub_v2.sql && \ sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0006_follower_activity_id.sql + < crates/adapters/sqlite/migrations/0006_follower_activity_id.sql && \ + sqlite3 /build/dev.db \ + < crates/adapters/sqlite/migrations/0007_user_role.sql ENV DATABASE_URL=sqlite:///build/dev.db diff --git a/crates/adapters/sqlite/migrations/0007_user_role.sql b/crates/adapters/sqlite/migrations/0007_user_role.sql new file mode 100644 index 0000000..c1a47db --- /dev/null +++ b/crates/adapters/sqlite/migrations/0007_user_role.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'standard'; diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 7018852..9b37feb 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -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 { 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, 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, 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, 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 diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index 9a48d08..b51f1fb 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -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 { diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs index c746c14..0740399 100644 --- a/crates/application/src/use_cases/register.rs +++ b/crates/application/src/use_cases/register.rs @@ -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 } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 7b68ccd..5582a17 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -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)] diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index a996190..911b7e1 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -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 FromRequestParts for AdminUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = axum::response::Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + 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::*; diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 362efc6..2d98f94 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -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?;