register users
This commit is contained in:
94
backend/Cargo.lock
generated
94
backend/Cargo.lock
generated
@@ -64,6 +64,18 @@ version = "1.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -234,6 +246,15 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -457,6 +478,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
@@ -1226,6 +1256,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -1306,6 +1342,17 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1369,6 +1416,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1825,6 +1878,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -1845,6 +1899,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -1901,6 +1956,7 @@ dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
@@ -1928,6 +1984,7 @@ dependencies = [
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
@@ -1943,6 +2000,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -1966,6 +2024,7 @@ dependencies = [
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
@@ -1978,6 +2037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1991,6 +2051,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -2112,6 +2173,37 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
@@ -2423,8 +2515,10 @@ name = "warren"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"axum_typed_multipart",
|
||||
"chrono",
|
||||
"derive_more",
|
||||
"dotenv",
|
||||
"mime_guess",
|
||||
|
||||
@@ -13,15 +13,17 @@ path = "src/bin/backend/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
argon2 = "0.5.3"
|
||||
axum = { version = "0.8.4", features = ["multipart", "query"] }
|
||||
axum_typed_multipart = "0.16.3"
|
||||
chrono = "0.4.41"
|
||||
derive_more = { version = "2.0.1", features = ["display"] }
|
||||
dotenv = "0.15.0"
|
||||
mime_guess = "2.0.5"
|
||||
regex = "1.11.1"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
|
||||
sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio", "time", "uuid"] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.46.1", features = ["full"] }
|
||||
tower = "0.5.2"
|
||||
|
||||
9
backend/migrations/20250716125144_create_users_table.sql
Normal file
9
backend/migrations/20250716125144_create_users_table.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
|
||||
name VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
hash VARCHAR NOT NULL,
|
||||
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
1
backend/migrations/20250716135209_user_email_unique.sql
Normal file
1
backend/migrations/20250716135209_user_email_unique.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
|
||||
28
backend/plan
Normal file
28
backend/plan
Normal file
@@ -0,0 +1,28 @@
|
||||
# Structure
|
||||
|
||||
## users
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- hash
|
||||
- admin
|
||||
- updated_at
|
||||
- created_at
|
||||
|
||||
## warrens
|
||||
- id
|
||||
- name
|
||||
- path
|
||||
- allow_children
|
||||
- updated_at
|
||||
- created_at
|
||||
|
||||
## user_warrens
|
||||
- user_id
|
||||
- warren_id
|
||||
- can_create_children (the user-specific flag, the warren's `allow_children` takes precedence so that both flags have to be enabled)
|
||||
- can_list_files (see and traverse the layout of the warren's directories and files)
|
||||
- can_read_files (read contents of the warren's files)
|
||||
- can_modify_files (edit contents of the warren's files)
|
||||
- can_delete_files (delete files and directories)
|
||||
- can_delete_warren (delete the warren and all its contents)
|
||||
@@ -33,12 +33,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);
|
||||
|
||||
let warren_service = domain::warren::service::warren::Service::new(
|
||||
postgres,
|
||||
postgres.clone(),
|
||||
metrics,
|
||||
notifier,
|
||||
fs_service.clone(),
|
||||
);
|
||||
|
||||
let auth_service = domain::warren::service::auth::Service::new(postgres, metrics, notifier);
|
||||
|
||||
let server_config = HttpServerConfig::new(
|
||||
&config.server_address,
|
||||
&config.server_port,
|
||||
@@ -46,7 +48,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
config.static_frontend_dir.as_deref(),
|
||||
);
|
||||
|
||||
let http_server = HttpServer::new(warren_service, server_config).await?;
|
||||
let http_server = HttpServer::new(warren_service, auth_service, server_config).await?;
|
||||
http_server.run().await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod file;
|
||||
pub mod user;
|
||||
pub mod warren;
|
||||
|
||||
102
backend/src/lib/domain/warren/models/user/mod.rs
Normal file
102
backend/src/lib/domain/warren/models/user/mod.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
mod requests;
|
||||
pub use requests::*;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use derive_more::Display;
|
||||
use regex::Regex;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
id: Uuid,
|
||||
name: UserName,
|
||||
email: UserEmail,
|
||||
hash: String,
|
||||
admin: bool,
|
||||
updated_at: NaiveDateTime,
|
||||
created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &UserName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &UserEmail {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn admin(&self) -> bool {
|
||||
self.admin
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> &NaiveDateTime {
|
||||
&self.updated_at
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &NaiveDateTime {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid username
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct UserName(String);
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum UserNameError {
|
||||
#[error("A username must not be empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl UserName {
|
||||
pub fn new(raw: &str) -> Result<Self, UserNameError> {
|
||||
let trimmed = raw.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(UserNameError::Empty);
|
||||
}
|
||||
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid email
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct UserEmail(String);
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum UserEmailError {
|
||||
#[error("A user's email must be a valid email")]
|
||||
Invalid,
|
||||
#[error("A user's email must not be empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
static USER_EMAIL_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[^@]+@[^@]+\.[^@]+$").expect("Email regex"));
|
||||
|
||||
impl UserEmail {
|
||||
pub fn new(raw: &str) -> Result<Self, UserEmailError> {
|
||||
let trimmed = raw.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(UserEmailError::Empty);
|
||||
}
|
||||
|
||||
if !USER_EMAIL_REGEX.is_match(&trimmed) {
|
||||
return Err(UserEmailError::Invalid);
|
||||
}
|
||||
|
||||
Ok(Self(raw.to_string()))
|
||||
}
|
||||
}
|
||||
89
backend/src/lib/domain/warren/models/user/requests.rs
Normal file
89
backend/src/lib/domain/warren/models/user/requests.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{UserEmail, UserName};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct RegisterUserRequest {
|
||||
name: UserName,
|
||||
email: UserEmail,
|
||||
password: UserPassword,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegisterUserError {
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// A valid user password
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UserPassword(String);
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum UserPasswordError {
|
||||
#[error("A user's password must not be empty")]
|
||||
Empty,
|
||||
#[error("A user's password must not start with a whitespace")]
|
||||
LeadingWhitespace,
|
||||
#[error("A user's password must not end with a whitespace")]
|
||||
TrailingWhitespace,
|
||||
#[error("A user's password must be longer")]
|
||||
TooShort,
|
||||
#[error("A user's password must be shorter")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl UserPassword {
|
||||
const MIN_LENGTH: usize = 12;
|
||||
const MAX_LENGTH: usize = 32;
|
||||
|
||||
pub fn new(raw: &str) -> Result<Self, UserPasswordError> {
|
||||
if raw.is_empty() {
|
||||
return Err(UserPasswordError::Empty);
|
||||
}
|
||||
|
||||
if raw.trim_start().len() != raw.len() {
|
||||
return Err(UserPasswordError::LeadingWhitespace);
|
||||
}
|
||||
|
||||
if raw.trim_end().len() != raw.len() {
|
||||
return Err(UserPasswordError::TrailingWhitespace);
|
||||
}
|
||||
|
||||
if raw.len() < Self::MIN_LENGTH {
|
||||
return Err(UserPasswordError::TooShort);
|
||||
}
|
||||
|
||||
if raw.len() > Self::MAX_LENGTH {
|
||||
return Err(UserPasswordError::TooLong);
|
||||
}
|
||||
|
||||
Ok(Self(raw.to_string()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterUserRequest {
|
||||
pub fn new(name: UserName, email: UserEmail, password: UserPassword) -> Self {
|
||||
Self {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &UserName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &UserEmail {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password(&self) -> &UserPassword {
|
||||
&self.password
|
||||
}
|
||||
}
|
||||
@@ -48,3 +48,8 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
|
||||
fn record_entry_rename_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_entry_rename_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait AuthMetrics: Clone + Send + Sync + 'static {
|
||||
fn record_user_registration_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_user_registration_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::models::{
|
||||
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
|
||||
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
||||
},
|
||||
user::{RegisterUserError, RegisterUserRequest, User},
|
||||
warren::{
|
||||
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
|
||||
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
|
||||
@@ -91,3 +92,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
|
||||
request: RenameEntryRequest,
|
||||
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send;
|
||||
}
|
||||
|
||||
pub trait AuthService: Clone + Send + Sync + 'static {
|
||||
fn register_user(
|
||||
&self,
|
||||
request: RegisterUserRequest,
|
||||
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::domain::warren::models::{
|
||||
file::{AbsoluteFilePath, File, FilePath},
|
||||
user::User,
|
||||
warren::Warren,
|
||||
};
|
||||
|
||||
@@ -68,3 +69,7 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
|
||||
new_path: &FilePath,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait AuthNotifier: Clone + Send + Sync + 'static {
|
||||
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::domain::warren::models::{
|
||||
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
|
||||
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
|
||||
},
|
||||
user::{RegisterUserError, RegisterUserRequest, User},
|
||||
warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren},
|
||||
};
|
||||
|
||||
@@ -48,3 +49,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
|
||||
request: RenameEntryRequest,
|
||||
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send;
|
||||
}
|
||||
|
||||
pub trait AuthRepository: Clone + Send + Sync + 'static {
|
||||
fn register_user(
|
||||
&self,
|
||||
request: RegisterUserRequest,
|
||||
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
|
||||
}
|
||||
|
||||
51
backend/src/lib/domain/warren/service/auth.rs
Normal file
51
backend/src/lib/domain/warren/service/auth.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::domain::warren::{
|
||||
models::user::{RegisterUserError, RegisterUserRequest, User},
|
||||
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Service<R, M, N>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
{
|
||||
repository: R,
|
||||
metrics: M,
|
||||
notifier: N,
|
||||
}
|
||||
|
||||
impl<R, M, N> Service<R, M, N>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
{
|
||||
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
metrics,
|
||||
notifier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, M, N> AuthService for Service<R, M, N>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
{
|
||||
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
||||
let result = self.repository.register_user(request).await;
|
||||
|
||||
if let Ok(user) = result.as_ref() {
|
||||
self.metrics.record_user_registration_success().await;
|
||||
self.notifier.user_registered(user).await;
|
||||
} else {
|
||||
self.metrics.record_user_registration_failure().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod file_system;
|
||||
pub mod warren;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
domain::warren::models::{
|
||||
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
|
||||
user::RegisterUserError,
|
||||
warren::{
|
||||
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
|
||||
FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError,
|
||||
@@ -116,12 +117,20 @@ impl From<ListWarrensError> for ApiError {
|
||||
|
||||
impl From<RenameWarrenEntryError> for ApiError {
|
||||
fn from(value: RenameWarrenEntryError) -> Self {
|
||||
ApiError::InternalServerError(value.to_string())
|
||||
Self::InternalServerError(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UploadWarrenFilesError> for ApiError {
|
||||
fn from(value: UploadWarrenFilesError) -> Self {
|
||||
ApiError::InternalServerError(value.to_string())
|
||||
Self::InternalServerError(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegisterUserError> for ApiError {
|
||||
fn from(value: RegisterUserError) -> Self {
|
||||
match value {
|
||||
RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/lib/inbound/http/handlers/auth/mod.rs
Normal file
13
backend/src/lib/inbound/http/handlers/auth/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod register;
|
||||
use register::register;
|
||||
|
||||
use axum::{Router, routing::post};
|
||||
|
||||
use crate::{
|
||||
domain::warren::ports::{AuthService, WarrenService},
|
||||
inbound::http::AppState,
|
||||
};
|
||||
|
||||
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
|
||||
Router::new().route("/register", post(register))
|
||||
}
|
||||
94
backend/src/lib/inbound/http/handlers/auth/register.rs
Normal file
94
backend/src/lib/inbound/http/handlers/auth/register.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::user::{
|
||||
RegisterUserRequest, UserEmail, UserEmailError, UserName, UserNameError, UserPassword,
|
||||
UserPasswordError,
|
||||
},
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
responses::{ApiError, ApiSuccess},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum ParseRegisterUserHttpRequestError {
|
||||
#[error(transparent)]
|
||||
UserName(#[from] UserNameError),
|
||||
#[error(transparent)]
|
||||
UserEmail(#[from] UserEmailError),
|
||||
#[error(transparent)]
|
||||
UserPassword(#[from] UserPasswordError),
|
||||
}
|
||||
|
||||
impl From<ParseRegisterUserHttpRequestError> for ApiError {
|
||||
fn from(value: ParseRegisterUserHttpRequestError) -> Self {
|
||||
match value {
|
||||
ParseRegisterUserHttpRequestError::UserName(err) => match err {
|
||||
UserNameError::Empty => {
|
||||
Self::BadRequest("The username must not be empty".to_string())
|
||||
}
|
||||
},
|
||||
ParseRegisterUserHttpRequestError::UserEmail(err) => match err {
|
||||
UserEmailError::Invalid => Self::BadRequest("Invalid email".to_string()),
|
||||
UserEmailError::Empty => {
|
||||
Self::BadRequest("The user email must not be empty".to_string())
|
||||
}
|
||||
},
|
||||
ParseRegisterUserHttpRequestError::UserPassword(err) => match err {
|
||||
UserPasswordError::Empty => {
|
||||
Self::BadRequest("The user password must not be empty".to_string())
|
||||
}
|
||||
UserPasswordError::LeadingWhitespace => Self::BadRequest(
|
||||
"The user password must not start with a whitespace".to_string(),
|
||||
),
|
||||
UserPasswordError::TrailingWhitespace => {
|
||||
Self::BadRequest("The user password must not end with a whitespace".to_string())
|
||||
}
|
||||
UserPasswordError::TooShort => {
|
||||
Self::BadRequest("The user password must be longer".to_string())
|
||||
}
|
||||
UserPasswordError::TooLong => {
|
||||
Self::BadRequest("The user password must be shorter".to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterUserHttpRequestBody {
|
||||
name: String,
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl RegisterUserHttpRequestBody {
|
||||
fn try_into_domain(self) -> Result<RegisterUserRequest, ParseRegisterUserHttpRequestError> {
|
||||
let name = UserName::new(&self.name)?;
|
||||
let email = UserEmail::new(&self.email)?;
|
||||
let password = UserPassword::new(&self.password)?;
|
||||
|
||||
Ok(RegisterUserRequest::new(name, email, password))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<RegisterUserHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
state
|
||||
.auth_service
|
||||
.register_user(domain_request)
|
||||
.await
|
||||
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod warrens;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||
warren::CreateWarrenDirectoryRequest,
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -62,13 +62,10 @@ impl CreateWarrenDirectoryHttpRequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_warren_directory<WS>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn create_warren_directory<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<CreateWarrenDirectoryHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError>
|
||||
where
|
||||
WS: WarrenService,
|
||||
{
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
state
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||
warren::DeleteWarrenDirectoryRequest,
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -64,13 +64,10 @@ impl DeleteWarrenDirectoryHttpRequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_warren_directory<WS>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn delete_warren_directory<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<DeleteWarrenDirectoryHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError>
|
||||
where
|
||||
WS: WarrenService,
|
||||
{
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
state
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||
warren::DeleteWarrenFileRequest,
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -62,13 +62,10 @@ impl DeleteWarrenFileHttpRequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_warren_file<WS>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn delete_warren_file<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<DeleteWarrenFileHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError>
|
||||
where
|
||||
WS: WarrenService,
|
||||
{
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
state
|
||||
|
||||
@@ -6,7 +6,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::warren::{FetchWarrenRequest, Warren},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -49,8 +49,8 @@ impl FetchWarrenHttpRequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_warren<WS: WarrenService>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn fetch_warren<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<FetchWarrenHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<FetchWarrenResponseData>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
file::{AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType},
|
||||
warren::ListWarrenFilesRequest,
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -94,8 +94,8 @@ impl From<Vec<File>> for ListWarrenFilesResponseData {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_warren_files<WS: WarrenService>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn list_warren_files<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<ListWarrenFilesHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<ListWarrenFilesResponseData>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::warren::{ListWarrensRequest, Warren},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -41,8 +41,8 @@ impl From<&Vec<Warren>> for ListWarrensResponseData {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_warrens<WS: WarrenService>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn list_warrens<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
) -> Result<ApiSuccess<ListWarrensResponseData>, ApiError> {
|
||||
let domain_request = ListWarrensRequest::new();
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ use axum::{
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
|
||||
use crate::{domain::warren::ports::WarrenService, inbound::http::AppState};
|
||||
use crate::{
|
||||
domain::warren::ports::{AuthService, WarrenService},
|
||||
inbound::http::AppState,
|
||||
};
|
||||
|
||||
use fetch_warren::fetch_warren;
|
||||
use list_warren_files::list_warren_files;
|
||||
@@ -26,7 +29,7 @@ use delete_warren_file::delete_warren_file;
|
||||
use rename_warren_entry::rename_warren_entry;
|
||||
use upload_warren_files::upload_warren_files;
|
||||
|
||||
pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> {
|
||||
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
|
||||
Router::new()
|
||||
.route("/", get(list_warrens))
|
||||
.route("/", post(fetch_warren))
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
},
|
||||
warren::RenameWarrenEntryRequest,
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -78,8 +78,8 @@ impl From<ParseRenameWarrenEntryHttpRequestError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename_warren_entry<WS: WarrenService>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn rename_warren_entry<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<RenameWarrenEntryHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
file::{AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError},
|
||||
warren::{UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesRequest},
|
||||
},
|
||||
ports::WarrenService,
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
@@ -92,13 +92,10 @@ impl From<ParseUploadWarrenFilesHttpRequestError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_warren_files<WS>(
|
||||
State(state): State<AppState<WS>>,
|
||||
pub async fn upload_warren_files<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
TypedMultipart(multipart): TypedMultipart<UploadWarrenFilesHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError>
|
||||
where
|
||||
WS: WarrenService,
|
||||
{
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = multipart.try_into_domain()?;
|
||||
|
||||
state
|
||||
|
||||
@@ -6,17 +6,18 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{Router, http::HeaderValue};
|
||||
use handlers::auth;
|
||||
use handlers::warrens;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
cors::CorsLayer,
|
||||
services::ServeDir,
|
||||
trace::{DefaultOnBodyChunk, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse},
|
||||
trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse},
|
||||
};
|
||||
use tracing::Level;
|
||||
|
||||
use crate::domain::warren::ports::WarrenService;
|
||||
use crate::domain::warren::ports::{AuthService, WarrenService};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HttpServerConfig<'a> {
|
||||
@@ -46,8 +47,9 @@ impl<'a> HttpServerConfig<'a> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState<WS: WarrenService> {
|
||||
pub struct AppState<WS: WarrenService, AS: AuthService> {
|
||||
warren_service: Arc<WS>,
|
||||
auth_service: Arc<AS>,
|
||||
}
|
||||
|
||||
pub struct HttpServer {
|
||||
@@ -56,8 +58,9 @@ pub struct HttpServer {
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
pub async fn new<WS: WarrenService>(
|
||||
pub async fn new<WS: WarrenService, AS: AuthService>(
|
||||
warren_service: WS,
|
||||
auth_service: AS,
|
||||
config: HttpServerConfig<'_>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let cors_layer = cors_layer(&config)?;
|
||||
@@ -73,6 +76,7 @@ impl HttpServer {
|
||||
|
||||
let state = AppState {
|
||||
warren_service: Arc::new(warren_service),
|
||||
auth_service: Arc::new(auth_service),
|
||||
};
|
||||
|
||||
let mut router = Router::new()
|
||||
@@ -118,6 +122,8 @@ fn cors_layer(config: &HttpServerConfig<'_>) -> anyhow::Result<CorsLayer> {
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
fn api_routes<WS: WarrenService>() -> Router<AppState<WS>> {
|
||||
Router::new().nest("/warrens", warrens::routes())
|
||||
fn api_routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
|
||||
Router::new()
|
||||
.nest("/auth", auth::routes())
|
||||
.nest("/warrens", warrens::routes())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::domain::warren::ports::{FileSystemMetrics, WarrenMetrics};
|
||||
use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MetricsDebugLogger;
|
||||
@@ -122,3 +122,12 @@ impl FileSystemMetrics for MetricsDebugLogger {
|
||||
tracing::debug!("[Metrics] Entry rename failed");
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthMetrics for MetricsDebugLogger {
|
||||
async fn record_user_registration_success(&self) {
|
||||
tracing::debug!("[Metrics] User registration succeeded");
|
||||
}
|
||||
async fn record_user_registration_failure(&self) {
|
||||
tracing::debug!("[Metrics] User registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::domain::warren::{
|
||||
models::{
|
||||
file::{File, FilePath},
|
||||
user::User,
|
||||
warren::Warren,
|
||||
},
|
||||
ports::{FileSystemNotifier, WarrenNotifier},
|
||||
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -112,3 +113,9 @@ impl FileSystemNotifier for NotifierDebugLogger {
|
||||
tracing::debug!("[Notifier] Renamed file {} to {}", old_path, new_path);
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthNotifier for NotifierDebugLogger {
|
||||
async fn user_registered(&self, user: &User) {
|
||||
tracing::debug!("[Notifier] Registered user {}", user.name());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use sqlx::{
|
||||
ConnectOptions as _, Connection as _, PgConnection, PgPool,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
@@ -8,10 +12,13 @@ use sqlx::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::{
|
||||
models::warren::{
|
||||
models::{
|
||||
user::{RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword},
|
||||
warren::{
|
||||
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
|
||||
},
|
||||
ports::WarrenRepository,
|
||||
},
|
||||
ports::{AuthRepository, WarrenRepository},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -98,6 +105,52 @@ impl Postgres {
|
||||
|
||||
Ok(warrens)
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
name: &UserName,
|
||||
email: &UserEmail,
|
||||
password: &UserPassword,
|
||||
is_admin: bool,
|
||||
) -> anyhow::Result<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_str().as_bytes(), &salt)
|
||||
.map_err(|_| anyhow!("Failed to hash password"))?
|
||||
.to_string();
|
||||
|
||||
let mut tx = connection.begin().await?;
|
||||
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (
|
||||
name,
|
||||
email,
|
||||
hash,
|
||||
admin
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.bind(is_admin)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
impl WarrenRepository for Postgres {
|
||||
@@ -143,6 +196,29 @@ impl WarrenRepository for Postgres {
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthRepository for Postgres {
|
||||
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
|
||||
let user = self
|
||||
.create_user(
|
||||
&mut connection,
|
||||
request.name(),
|
||||
request.email(),
|
||||
request.password(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.context(format!("Failed to create user"))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_not_found_error(err: &sqlx::Error) -> bool {
|
||||
matches!(err, sqlx::Error::RowNotFound)
|
||||
}
|
||||
|
||||
@@ -27,4 +27,8 @@ body,
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'TikTok Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
22
frontend/components/ui/card/Card.vue
Normal file
22
frontend/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardAction.vue
Normal file
17
frontend/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardContent.vue
Normal file
17
frontend/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardDescription.vue
Normal file
17
frontend/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardFooter.vue
Normal file
17
frontend/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardHeader.vue
Normal file
17
frontend/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/card/CardTitle.vue
Normal file
17
frontend/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
frontend/components/ui/card/index.ts
Normal file
7
frontend/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardAction } from './CardAction.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardFooter } from './CardFooter.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
5
frontend/layouts/auth.vue
Normal file
5
frontend/layouts/auth.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<main class="flex h-full w-full items-center justify-center">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
@@ -90,9 +90,3 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
font-family: 'TikTok Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
0
frontend/lib/api/auth/index.ts
Normal file
0
frontend/lib/api/auth/index.ts
Normal file
42
frontend/lib/api/auth/register.ts
Normal file
42
frontend/lib/api/auth/register.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
import type { ApiResponse } from '~/types/api';
|
||||
|
||||
export async function registerUser(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const { data, error } = await useFetch<ApiResponse<undefined>>(
|
||||
getApiUrl('auth/register'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: username,
|
||||
email: email,
|
||||
password: password,
|
||||
}),
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
toast.error('Register', {
|
||||
description: error.value?.data ?? 'Failed to register',
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
toast.success('Register', {
|
||||
description: `Successfully registered user ${username}`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
52
frontend/pages/login.vue
Normal file
52
frontend/pages/login.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
// TODO: Get this from the backend
|
||||
const OPEN_ID = false;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex-col gap-2">
|
||||
<Button class="w-full">Sign in</Button>
|
||||
<Button class="w-full" variant="outline" :disabled="!OPEN_ID"
|
||||
>OpenID Connect</Button
|
||||
>
|
||||
<NuxtLink to="/register" class="w-full">
|
||||
<Button class="w-full" variant="ghost">Register instead</Button>
|
||||
</NuxtLink>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
95
frontend/pages/register.vue
Normal file
95
frontend/pages/register.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import { registerUser } from '~/lib/api/auth/register';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const registering = ref(false);
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const allFieldsValid = computed(
|
||||
() =>
|
||||
username.value.trim().length > 0 &&
|
||||
email.value.trim().length > 0 &&
|
||||
password.value.trim().length > 0
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
registering.value = true;
|
||||
|
||||
const { success } = await registerUser(
|
||||
username.value,
|
||||
email.value,
|
||||
password.value
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await navigateTo({ path: '/login' });
|
||||
return;
|
||||
}
|
||||
|
||||
registering.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">Register</CardTitle>
|
||||
<CardDescription> Create a new user account </CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="username"
|
||||
placeholder="409"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex-col gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="!allFieldsValid || registering"
|
||||
@click="submit"
|
||||
>Register</Button
|
||||
>
|
||||
<NuxtLink to="/login" class="w-full">
|
||||
<Button class="w-full" variant="ghost">Log in instead</Button>
|
||||
</NuxtLink>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -2,3 +2,6 @@ export type ApiResponse<T> = {
|
||||
status: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
// TODO: Fix
|
||||
export type ApiError = string;
|
||||
|
||||
Reference in New Issue
Block a user