register users

This commit is contained in:
2025-07-16 18:37:26 +02:00
parent 990f196984
commit be362326aa
48 changed files with 1002 additions and 64 deletions

94
backend/Cargo.lock generated
View File

@@ -64,6 +64,18 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 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]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@@ -234,6 +246,15 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -457,6 +478,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.0.1" version = "2.0.1"
@@ -1226,6 +1256,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1306,6 +1342,17 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1369,6 +1416,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1825,6 +1878,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@@ -1845,6 +1899,7 @@ dependencies = [
"sha2", "sha2",
"smallvec", "smallvec",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -1901,6 +1956,7 @@ dependencies = [
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest",
"dotenvy", "dotenvy",
@@ -1928,6 +1984,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@@ -1943,6 +2000,7 @@ dependencies = [
"base64", "base64",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@@ -1966,6 +2024,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@@ -1978,6 +2037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -1991,6 +2051,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
@@ -2112,6 +2173,37 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.1"
@@ -2423,8 +2515,10 @@ name = "warren"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2",
"axum", "axum",
"axum_typed_multipart", "axum_typed_multipart",
"chrono",
"derive_more", "derive_more",
"dotenv", "dotenv",
"mime_guess", "mime_guess",

View File

@@ -13,15 +13,17 @@ path = "src/bin/backend/main.rs"
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
argon2 = "0.5.3"
axum = { version = "0.8.4", features = ["multipart", "query"] } axum = { version = "0.8.4", features = ["multipart", "query"] }
axum_typed_multipart = "0.16.3" axum_typed_multipart = "0.16.3"
chrono = "0.4.41"
derive_more = { version = "2.0.1", features = ["display"] } derive_more = { version = "2.0.1", features = ["display"] }
dotenv = "0.15.0" dotenv = "0.15.0"
mime_guess = "2.0.5" mime_guess = "2.0.5"
regex = "1.11.1" regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" 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" thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] } tokio = { version = "1.46.1", features = ["full"] }
tower = "0.5.2" tower = "0.5.2"

View 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
);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

28
backend/plan Normal file
View 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)

View File

@@ -33,12 +33,14 @@ async fn main() -> anyhow::Result<()> {
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier); let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);
let warren_service = domain::warren::service::warren::Service::new( let warren_service = domain::warren::service::warren::Service::new(
postgres, postgres.clone(),
metrics, metrics,
notifier, notifier,
fs_service.clone(), fs_service.clone(),
); );
let auth_service = domain::warren::service::auth::Service::new(postgres, metrics, notifier);
let server_config = HttpServerConfig::new( let server_config = HttpServerConfig::new(
&config.server_address, &config.server_address,
&config.server_port, &config.server_port,
@@ -46,7 +48,7 @@ async fn main() -> anyhow::Result<()> {
config.static_frontend_dir.as_deref(), 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?; http_server.run().await?;
Ok(()) Ok(())

View File

@@ -1,2 +1,3 @@
pub mod file; pub mod file;
pub mod user;
pub mod warren; pub mod warren;

View 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()))
}
}

View 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
}
}

View File

@@ -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_success(&self) -> impl Future<Output = ()> + Send;
fn record_entry_rename_failure(&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;
}

View File

@@ -12,6 +12,7 @@ use super::models::{
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
}, },
user::{RegisterUserError, RegisterUserRequest, User},
warren::{ warren::{
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
@@ -91,3 +92,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
request: RenameEntryRequest, request: RenameEntryRequest,
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send; ) -> 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;
}

View File

@@ -1,5 +1,6 @@
use crate::domain::warren::models::{ use crate::domain::warren::models::{
file::{AbsoluteFilePath, File, FilePath}, file::{AbsoluteFilePath, File, FilePath},
user::User,
warren::Warren, warren::Warren,
}; };
@@ -68,3 +69,7 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
new_path: &FilePath, new_path: &FilePath,
) -> impl Future<Output = ()> + Send; ) -> impl Future<Output = ()> + Send;
} }
pub trait AuthNotifier: Clone + Send + Sync + 'static {
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
}

View File

@@ -4,6 +4,7 @@ use crate::domain::warren::models::{
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
}, },
user::{RegisterUserError, RegisterUserRequest, User},
warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren}, warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren},
}; };
@@ -48,3 +49,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
request: RenameEntryRequest, request: RenameEntryRequest,
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send; ) -> 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;
}

View 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
}
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod file_system; pub mod file_system;
pub mod warren; pub mod warren;

View File

@@ -1,6 +1,7 @@
use crate::{ use crate::{
domain::warren::models::{ domain::warren::models::{
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError}, file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
user::RegisterUserError,
warren::{ warren::{
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError, CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError, FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError,
@@ -116,12 +117,20 @@ impl From<ListWarrensError> for ApiError {
impl From<RenameWarrenEntryError> for ApiError { impl From<RenameWarrenEntryError> for ApiError {
fn from(value: RenameWarrenEntryError) -> Self { fn from(value: RenameWarrenEntryError) -> Self {
ApiError::InternalServerError(value.to_string()) Self::InternalServerError(value.to_string())
} }
} }
impl From<UploadWarrenFilesError> for ApiError { impl From<UploadWarrenFilesError> for ApiError {
fn from(value: UploadWarrenFilesError) -> Self { 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()),
}
} }
} }

View 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))
}

View 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)
}

View File

@@ -1 +1,2 @@
pub mod auth;
pub mod warrens; pub mod warrens;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError}, file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::CreateWarrenDirectoryRequest, warren::CreateWarrenDirectoryRequest,
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -62,13 +62,10 @@ impl CreateWarrenDirectoryHttpRequestBody {
} }
} }
pub async fn create_warren_directory<WS>( pub async fn create_warren_directory<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<CreateWarrenDirectoryHttpRequestBody>, Json(request): Json<CreateWarrenDirectoryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> ) -> Result<ApiSuccess<()>, ApiError> {
where
WS: WarrenService,
{
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;
state state

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError}, file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::DeleteWarrenDirectoryRequest, warren::DeleteWarrenDirectoryRequest,
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -64,13 +64,10 @@ impl DeleteWarrenDirectoryHttpRequestBody {
} }
} }
pub async fn delete_warren_directory<WS>( pub async fn delete_warren_directory<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<DeleteWarrenDirectoryHttpRequestBody>, Json(request): Json<DeleteWarrenDirectoryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> ) -> Result<ApiSuccess<()>, ApiError> {
where
WS: WarrenService,
{
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;
state state

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError}, file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::DeleteWarrenFileRequest, warren::DeleteWarrenFileRequest,
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -62,13 +62,10 @@ impl DeleteWarrenFileHttpRequestBody {
} }
} }
pub async fn delete_warren_file<WS>( pub async fn delete_warren_file<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<DeleteWarrenFileHttpRequestBody>, Json(request): Json<DeleteWarrenFileHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> ) -> Result<ApiSuccess<()>, ApiError> {
where
WS: WarrenService,
{
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;
state state

View File

@@ -6,7 +6,7 @@ use uuid::Uuid;
use crate::{ use crate::{
domain::warren::{ domain::warren::{
models::warren::{FetchWarrenRequest, Warren}, models::warren::{FetchWarrenRequest, Warren},
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -49,8 +49,8 @@ impl FetchWarrenHttpRequestBody {
} }
} }
pub async fn fetch_warren<WS: WarrenService>( pub async fn fetch_warren<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<FetchWarrenHttpRequestBody>, Json(request): Json<FetchWarrenHttpRequestBody>,
) -> Result<ApiSuccess<FetchWarrenResponseData>, ApiError> { ) -> Result<ApiSuccess<FetchWarrenResponseData>, ApiError> {
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType}, file::{AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType},
warren::ListWarrenFilesRequest, warren::ListWarrenFilesRequest,
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -94,8 +94,8 @@ impl From<Vec<File>> for ListWarrenFilesResponseData {
} }
} }
pub async fn list_warren_files<WS: WarrenService>( pub async fn list_warren_files<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<ListWarrenFilesHttpRequestBody>, Json(request): Json<ListWarrenFilesHttpRequestBody>,
) -> Result<ApiSuccess<ListWarrenFilesResponseData>, ApiError> { ) -> Result<ApiSuccess<ListWarrenFilesResponseData>, ApiError> {
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;

View File

@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{ use crate::{
domain::warren::{ domain::warren::{
models::warren::{ListWarrensRequest, Warren}, models::warren::{ListWarrensRequest, Warren},
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -41,8 +41,8 @@ impl From<&Vec<Warren>> for ListWarrensResponseData {
} }
} }
pub async fn list_warrens<WS: WarrenService>( pub async fn list_warrens<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
) -> Result<ApiSuccess<ListWarrensResponseData>, ApiError> { ) -> Result<ApiSuccess<ListWarrensResponseData>, ApiError> {
let domain_request = ListWarrensRequest::new(); let domain_request = ListWarrensRequest::new();

View File

@@ -13,7 +13,10 @@ use axum::{
routing::{delete, get, patch, post}, 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 fetch_warren::fetch_warren;
use list_warren_files::list_warren_files; 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 rename_warren_entry::rename_warren_entry;
use upload_warren_files::upload_warren_files; 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() Router::new()
.route("/", get(list_warrens)) .route("/", get(list_warrens))
.route("/", post(fetch_warren)) .route("/", post(fetch_warren))

View File

@@ -12,7 +12,7 @@ use crate::{
}, },
warren::RenameWarrenEntryRequest, warren::RenameWarrenEntryRequest,
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -78,8 +78,8 @@ impl From<ParseRenameWarrenEntryHttpRequestError> for ApiError {
} }
} }
pub async fn rename_warren_entry<WS: WarrenService>( pub async fn rename_warren_entry<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
Json(request): Json<RenameWarrenEntryHttpRequestBody>, Json(request): Json<RenameWarrenEntryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> { ) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?; let domain_request = request.try_into_domain()?;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError}, file::{AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError},
warren::{UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesRequest}, warren::{UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesRequest},
}, },
ports::WarrenService, ports::{AuthService, WarrenService},
}, },
inbound::http::{ inbound::http::{
AppState, AppState,
@@ -92,13 +92,10 @@ impl From<ParseUploadWarrenFilesHttpRequestError> for ApiError {
} }
} }
pub async fn upload_warren_files<WS>( pub async fn upload_warren_files<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS>>, State(state): State<AppState<WS, AS>>,
TypedMultipart(multipart): TypedMultipart<UploadWarrenFilesHttpRequestBody>, TypedMultipart(multipart): TypedMultipart<UploadWarrenFilesHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> ) -> Result<ApiSuccess<()>, ApiError> {
where
WS: WarrenService,
{
let domain_request = multipart.try_into_domain()?; let domain_request = multipart.try_into_domain()?;
state state

View File

@@ -6,17 +6,18 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use axum::{Router, http::HeaderValue}; use axum::{Router, http::HeaderValue};
use handlers::auth;
use handlers::warrens; use handlers::warrens;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
services::ServeDir, services::ServeDir,
trace::{DefaultOnBodyChunk, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse}, trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse},
}; };
use tracing::Level; use tracing::Level;
use crate::domain::warren::ports::WarrenService; use crate::domain::warren::ports::{AuthService, WarrenService};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpServerConfig<'a> { pub struct HttpServerConfig<'a> {
@@ -46,8 +47,9 @@ impl<'a> HttpServerConfig<'a> {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AppState<WS: WarrenService> { pub struct AppState<WS: WarrenService, AS: AuthService> {
warren_service: Arc<WS>, warren_service: Arc<WS>,
auth_service: Arc<AS>,
} }
pub struct HttpServer { pub struct HttpServer {
@@ -56,8 +58,9 @@ pub struct HttpServer {
} }
impl HttpServer { impl HttpServer {
pub async fn new<WS: WarrenService>( pub async fn new<WS: WarrenService, AS: AuthService>(
warren_service: WS, warren_service: WS,
auth_service: AS,
config: HttpServerConfig<'_>, config: HttpServerConfig<'_>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let cors_layer = cors_layer(&config)?; let cors_layer = cors_layer(&config)?;
@@ -73,6 +76,7 @@ impl HttpServer {
let state = AppState { let state = AppState {
warren_service: Arc::new(warren_service), warren_service: Arc::new(warren_service),
auth_service: Arc::new(auth_service),
}; };
let mut router = Router::new() let mut router = Router::new()
@@ -118,6 +122,8 @@ fn cors_layer(config: &HttpServerConfig<'_>) -> anyhow::Result<CorsLayer> {
Ok(layer) Ok(layer)
} }
fn api_routes<WS: WarrenService>() -> Router<AppState<WS>> { fn api_routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new().nest("/warrens", warrens::routes()) Router::new()
.nest("/auth", auth::routes())
.nest("/warrens", warrens::routes())
} }

View File

@@ -1,4 +1,4 @@
use crate::domain::warren::ports::{FileSystemMetrics, WarrenMetrics}; use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct MetricsDebugLogger; pub struct MetricsDebugLogger;
@@ -122,3 +122,12 @@ impl FileSystemMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] Entry rename failed"); 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");
}
}

View File

@@ -1,9 +1,10 @@
use crate::domain::warren::{ use crate::domain::warren::{
models::{ models::{
file::{File, FilePath}, file::{File, FilePath},
user::User,
warren::Warren, warren::Warren,
}, },
ports::{FileSystemNotifier, WarrenNotifier}, ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
}; };
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -112,3 +113,9 @@ impl FileSystemNotifier for NotifierDebugLogger {
tracing::debug!("[Notifier] Renamed file {} to {}", old_path, new_path); 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());
}
}

View File

@@ -1,6 +1,10 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context, anyhow}; use anyhow::{Context, anyhow};
use argon2::{
Argon2,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use sqlx::{ use sqlx::{
ConnectOptions as _, Connection as _, PgConnection, PgPool, ConnectOptions as _, Connection as _, PgConnection, PgPool,
postgres::{PgConnectOptions, PgPoolOptions}, postgres::{PgConnectOptions, PgPoolOptions},
@@ -8,10 +12,13 @@ use sqlx::{
use uuid::Uuid; use uuid::Uuid;
use crate::domain::warren::{ use crate::domain::warren::{
models::warren::{ models::{
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren, user::{RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword},
warren::{
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
},
}, },
ports::WarrenRepository, ports::{AuthRepository, WarrenRepository},
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -98,6 +105,52 @@ impl Postgres {
Ok(warrens) 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 { 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 { fn is_not_found_error(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::RowNotFound) matches!(err, sqlx::Error::RowNotFound)
} }

View File

@@ -27,4 +27,8 @@ body,
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
* {
font-family: 'TikTok Sans', sans-serif;
}
</style> </style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View File

@@ -0,0 +1,5 @@
<template>
<main class="flex h-full w-full items-center justify-center">
<slot />
</main>
</template>

View File

@@ -90,9 +90,3 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
</main> </main>
</SidebarProvider> </SidebarProvider>
</template> </template>
<style>
* {
font-family: 'TikTok Sans', sans-serif;
}
</style>

View File

View 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
View 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>

View 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>

View File

@@ -2,3 +2,6 @@ export type ApiResponse<T> = {
status: number; status: number;
data: T; data: T;
}; };
// TODO: Fix
export type ApiError = string;