1
0

Reorganize & implement Authentication view api

This commit is contained in:
2025-02-02 22:41:32 +02:00
parent da50312a40
commit 88cd107e14
69 changed files with 1089 additions and 343 deletions

View File

@ -1,3 +1,2 @@
/target/
/**/scrapyard/
/database/data/

View File

@ -326,17 +326,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "authentication"
version = "0.1.0"
dependencies = [
"argon2",
"database",
"derive_more",
"garde",
"thiserror 2.0.11",
]
[[package]]
name = "autocfg"
version = "1.4.0"
@ -911,7 +900,7 @@ dependencies = [
]
[[package]]
name = "database"
name = "data"
version = "0.1.0"
dependencies = [
"chrono",
@ -1791,6 +1780,7 @@ dependencies = [
"iced_core",
"log",
"rustc-hash 2.1.0",
"tokio",
"wasm-bindgen-futures",
"wasm-timer",
]
@ -2260,18 +2250,12 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
[[package]]
name = "macros"
version = "0.1.0"
[[package]]
name = "main"
version = "0.1.0"
dependencies = [
"iced",
"macros",
"strum",
"widget",
]
[[package]]
@ -3464,6 +3448,17 @@ dependencies = [
"serde",
]
[[package]]
name = "service"
version = "0.1.0"
dependencies = [
"argon2",
"data",
"derive_more",
"garde",
"thiserror 2.0.11",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -4323,6 +4318,13 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "view"
version = "0.1.0"
dependencies = [
"iced",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@ -4692,13 +4694,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
[[package]]
name = "widget"
version = "0.1.0"
dependencies = [
"iced",
]
[[package]]
name = "winapi"
version = "0.3.9"

View File

@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["app/*", "libs/*", "database"]
members = ["data", "main", "service", "view"]

View File

@ -1,4 +0,0 @@
// use database::port::user;
mod repository;
mod service;

View File

@ -1,212 +0,0 @@
use crate::repository::{Authenticated, AuthenticationRepository};
use database::port::user;
use derive_more::{Deref, Into};
use garde::Validate;
#[derive(thiserror::Error, Debug)]
pub enum Error {
// Login
#[error("login not found")]
LoginNotFound,
#[error("incorrect password")]
IncorrectPassword,
// Register
#[error("username is taken")]
NameExists,
#[error("email is already in use")]
EmailExists,
// Shared
#[error("invalid password: {0}")]
InvalidPassword(Box<dyn std::error::Error>),
#[error("data source error: {0}")]
Repository(Box<dyn std::error::Error>),
}
pub type Result<T = (), E = Error> = std::result::Result<T, E>;
#[derive(Clone, Deref, Into)]
pub struct Name(user::Name);
impl TryFrom<String> for Name {
type Error = Box<dyn std::error::Error>;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
struct Username<'a>(#[garde(alphanumeric, length(chars, min = 2, max = 31))] &'a str);
Username(&value).validate()?;
Ok(Self(user::Name::try_from(value)?))
}
}
#[derive(Clone, Deref, Into)]
pub struct Email(user::Email);
impl TryFrom<String> for Email {
type Error = Box<dyn std::error::Error>;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
pub struct Email<'a>(#[garde(email, length(chars, max = 255))] &'a str);
Email(&value).validate()?;
Ok(Self(user::Email::try_from(value)?))
}
}
// #[derive(Validate, Deref, From, Into)]
// #[garde(transparent)]
// pub struct Password(#[garde(length(chars, min = 8))] pub String);
pub enum LoginBy {
Name(Name),
Email(Email),
}
pub struct LoginData {
login: LoginBy,
password: String,
}
pub struct RegisterData {
pub name: Name,
pub email: Email,
pub password: String,
}
pub trait AuthenticationContract {
async fn name_available(&self, name: Name) -> Result;
async fn email_available(&self, email: Email) -> Result;
async fn login(&mut self, data: LoginData) -> Result<Authenticated>;
async fn register(&mut self, data: RegisterData) -> Result<Authenticated>;
}
// Service
use crate::repository::Get;
use argon2::{
Argon2,
password_hash::{
self, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
},
};
impl From<password_hash::Error> for Error {
fn from(error: password_hash::Error) -> Self {
match error {
password_hash::Error::Password => Self::IncorrectPassword,
_ => Self::InvalidPassword(error.into()),
}
}
}
pub struct AuthenticationService<R>
where
R: AuthenticationRepository,
{
repository: R,
}
impl<R> AuthenticationService<R>
where
R: AuthenticationRepository,
{
pub const fn new(repository: R) -> Self {
Self { repository }
}
}
impl<R> AuthenticationContract for AuthenticationService<R>
where
R: AuthenticationRepository,
{
async fn name_available(&self, name: Name) -> Result {
if self
.repository
.get_user(Get::Name(name.0))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::NameExists);
};
Ok(())
}
async fn email_available(&self, email: Email) -> Result {
if self
.repository
.get_user(Get::Email(email.0))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::EmailExists);
};
Ok(())
}
async fn login(&mut self, data: LoginData) -> Result<Authenticated> {
if data.password.chars().count() < 8 {
return Err(Error::InvalidPassword(
"password must be longer than 8 characters".into(),
));
}
let user = match data.login {
LoginBy::Name(name) => self.repository.get_user(Get::Name(name.0)),
LoginBy::Email(email) => self.repository.get_user(Get::Email(email.0)),
}
.await
.map_err(Error::Repository)?
.ok_or(Error::LoginNotFound)?;
Argon2::default().verify_password(
data.password.as_bytes(),
&PasswordHash::new(user.password())?,
)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
async fn register(&mut self, data: RegisterData) -> Result<Authenticated> {
if data.password.chars().count() < 8 {
return Err(Error::InvalidPassword(
"password must be longer than 8 characters".into(),
));
}
self.name_available(data.name.clone()).await?;
self.email_available(data.email.clone()).await?;
// Get PHC string ($argon2id$v=19$...)
let password = Argon2::default()
.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng))?
.to_string()
.try_into()
.map_err(|e| Error::InvalidPassword(Box::from(e)))?;
let user = self
.repository
.create_user(user::New {
name: data.name.0,
email: data.email.0,
password,
last_used: None,
})
.await
.map_err(Error::Repository)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
}

View File

@ -5,7 +5,7 @@ services:
ports:
- "127.0.0.1:3306:3306"
volumes:
- ./data:/var/lib/mysql
- ../../repo-database:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d/:ro
environment:
MYSQL_ROOT_PASSWORD: password # yes, I know

View File

@ -1,5 +1,5 @@
[package]
name = "database"
name = "data"
version = "0.1.0"
edition = "2024"

View File

@ -0,0 +1,2 @@
//! Specific implementations of [`crate::port`]s to plug into other parts of the application.
pub mod mysql;

View File

@ -1,3 +1,4 @@
//! `MySQL` adapters.
pub mod base;
pub mod package;
pub mod user;

View File

@ -1,5 +1,7 @@
pub use crate::port::base::*;
use crate::Result;
use crate::port::base::{Base, BaseRepository, Field, New};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct BaseAdapter;

View File

@ -1,5 +1,7 @@
pub use crate::port::package::*;
use crate::Result;
use crate::port::package::{Field, New, Package, PackageRepository, Unique};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct PackageAdapter;

View File

@ -1,5 +1,7 @@
pub use crate::port::user::*;
use crate::Result;
use crate::port::user::{Field, New, Unique, User, UserRepository};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct UserAdapter;

View File

@ -1,11 +1,15 @@
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
//! Unify transaction management for established connections.
use crate::Result;
pub trait Atomic {
type Transaction<'a>;
fn start_transaction(&mut self) -> impl Future<Output = Result<Self::Transaction<'_>>> + Send;
fn abort_transaction(transaction: Self::Transaction<'_>) -> impl Future<Output = Result> + Send;
fn commit_transaction(transaction: Self::Transaction<'_>) -> impl Future<Output = Result> + Send;
fn abort_transaction(transaction: Self::Transaction<'_>)
-> impl Future<Output = Result> + Send;
fn commit_transaction(
transaction: Self::Transaction<'_>,
) -> impl Future<Output = Result> + Send;
}
use sqlx::Connection;

View File

@ -1,4 +1,5 @@
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
//! Driver to manage a connection which is passed to adapters.
use crate::Result;
pub trait Connect {
type Connection;
@ -8,13 +9,20 @@ pub trait Connect {
}
use sqlx::Connection;
pub use sqlx::MySqlConnection as SqlxConnection;
pub use sqlx::MySqlPool as SqlxPool;
#[derive(Clone)]
pub struct MySqlPool {
pool: sqlx::MySqlPool,
pool: SqlxPool,
}
impl MySqlPool {
pub const fn new(pool: SqlxPool) -> Self {
Self { pool }
}
}
impl Connect for MySqlPool {
type Connection = sqlx::MySqlPool;
type Connection = SqlxPool;
async fn open_connection(&self) -> Result<Self::Connection> {
Ok(self.pool.clone())
@ -27,14 +35,16 @@ impl Connect for MySqlPool {
pub struct MySqlConnection {
link: String,
}
impl MySqlConnection {
pub const fn new(link: String) -> Self {
Self { link }
}
}
impl Connect for MySqlConnection {
type Connection = sqlx::MySqlConnection;
type Connection = SqlxConnection;
async fn open_connection(&self) -> Result<Self::Connection> {
sqlx::MySqlConnection::connect(&self.link)
.await
.map_err(Box::from)
SqlxConnection::connect(&self.link).await.map_err(Box::from)
}
async fn close_connection(connection: Self::Connection) -> Result {
connection.close().await?;

View File

@ -0,0 +1,21 @@
//! Data access for the application.
pub mod adapter;
pub mod atomic;
pub mod connect;
pub mod port;
// Don't want to handle errors for dynamic mess.
pub type BoxDynError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type Result<T = (), E = BoxDynError> = std::result::Result<T, E>;
pub use chrono::Utc;
pub use atomic::Atomic;
pub use connect::*;
pub use adapter::mysql::base::BaseAdapter as MySqlBaseAdapter;
pub use adapter::mysql::package::PackageAdapter as MySqlPackageAdapter;
pub use adapter::mysql::user::UserAdapter as MySqlUserAdapter;
pub use port::base::{self, Base, BaseRepository};
pub use port::package::{self, Package, PackageRepository};
pub use port::user::{self, User, UserRepository};

View File

@ -1,4 +1,11 @@
pub type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
//! Low-level repository traits for unified data access.
//!
//! No data validation besides very basic one like length violation.
use crate::Result;
pub mod base;
pub mod package;
pub mod user;
pub trait CRUD<C> {
type New;
@ -9,17 +16,18 @@ pub trait CRUD<C> {
fn create(
connection: &mut C,
data: Self::New,
) -> impl Future<Output = Result<Self::Existing>> + Send;
) -> impl Future<Output = crate::Result<Self::Existing>> + Send;
fn read(
connection: &C,
data: Self::Unique,
) -> impl Future<Output = Result<Option<Self::Existing>>> + Send;
) -> impl Future<Output = crate::Result<Option<Self::Existing>>> + Send;
fn update(
connection: &mut C,
existing: &mut Self::Existing,
data: Self::Update,
) -> impl Future<Output = Result> + Send;
fn delete(connection: &mut C, data: Self::Unique) -> impl Future<Output = Result> + Send;
) -> impl Future<Output = crate::Result> + Send;
fn delete(connection: &mut C, data: Self::Unique)
-> impl Future<Output = crate::Result> + Send;
}
trait CharLength {
@ -48,9 +56,3 @@ trait MaxLength {
}
}
}
// const TOO_LONG: &str = "too long";
pub mod base;
pub mod package;
pub mod user;

View File

@ -1,11 +1,10 @@
use super::MaxLength;
pub use super::{CRUD, Result};
pub use chrono::{DateTime, Utc};
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait BaseRepository<C>:
CRUD<C, New = New, Unique = u64, Update = Field, Existing = Base>
super::CRUD<C, New = New, Unique = u64, Update = Field, Existing = Base>
{
}
@ -34,11 +33,13 @@ impl MaxLength for Description {
const MAX_LENGTH: usize = 510;
}
impl TryFrom<Option<String>> for Description {
type Error = &'static str;
type Error = (Option<String>, &'static str);
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}

View File

@ -1,11 +1,11 @@
use super::MaxLength;
pub use super::{CRUD, Result, base::Base};
use crate::Base;
pub use chrono::{DateTime, Utc};
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait PackageRepository<C>:
CRUD<C, New = New, Update = Field, Unique = Unique, Existing = Package>
super::CRUD<C, New = New, Update = Field, Unique = Unique, Existing = Package>
{
}
@ -16,11 +16,13 @@ impl MaxLength for Name {
const MAX_LENGTH: usize = 127;
}
impl TryFrom<String> for Name {
type Error = &'static str;
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -31,11 +33,13 @@ impl MaxLength for Version {
const MAX_LENGTH: usize = 127;
}
impl TryFrom<String> for Version {
type Error = &'static str;
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -46,11 +50,13 @@ impl MaxLength for Description {
const MAX_LENGTH: usize = 255;
}
impl TryFrom<Option<String>> for Description {
type Error = &'static str;
type Error = (Option<String>, &'static str);
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -61,11 +67,13 @@ impl MaxLength for URL {
const MAX_LENGTH: usize = 510;
}
impl TryFrom<Option<String>> for URL {
type Error = &'static str;
type Error = (Option<String>, &'static str);
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}

View File

@ -1,11 +1,10 @@
use super::MaxLength;
pub use super::{CRUD, Result};
pub use chrono::{DateTime, Utc};
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait UserRepository<C>:
CRUD<C, New = New, Update = Field, Unique = Unique, Existing = User>
super::CRUD<C, New = New, Update = Field, Unique = Unique, Existing = User>
{
}
@ -16,11 +15,13 @@ impl MaxLength for Name {
const MAX_LENGTH: usize = 31;
}
impl TryFrom<String> for Name {
type Error = &'static str;
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -31,11 +32,13 @@ impl MaxLength for Email {
const MAX_LENGTH: usize = 255;
}
impl TryFrom<String> for Email {
type Error = &'static str;
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -46,11 +49,13 @@ impl MaxLength for Password {
const MAX_LENGTH: usize = 255;
}
impl TryFrom<String> for Password {
type Error = &'static str;
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
match Self::validate(&value) {
Ok(()) => Ok(Self(value)),
Err(e) => Err((value, e)),
}
}
}
@ -76,6 +81,7 @@ pub struct New {
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug)]
pub struct User {
pub(crate) id: u64,
pub(crate) name: String,

View File

@ -1 +0,0 @@
pub mod mysql;

View File

@ -1,4 +0,0 @@
pub mod adapter;
pub mod atomic;
pub mod connect;
pub mod port;

View File

@ -1,14 +1,13 @@
[package]
name = "authentication"
name = "service"
version = "0.1.0"
edition = "2024"
[dependencies]
argon2 = { version = "0.5.3", features = ["std"] }
thiserror = "2.0.11"
argon2 = { version = "0.5.3", features = ["std"] }
garde = { version = "0.22.0", features = ["email", "url", "derive"] }
derive_more = { version = "1.0.0", features = ["deref", "deref_mut", "into"] }
[dependencies.database]
path = "../../database"
[dependencies.data]
path = "../data"

View File

@ -0,0 +1,9 @@
pub mod adapter;
pub mod contract;
pub mod repository;
pub mod service;
pub use adapter::*;
pub use contract::*;
pub use repository::*;
pub use service::*;

View File

@ -1,33 +1,7 @@
pub use database::port::user::*;
use super::{Authenticated, AuthenticationRepository, Get};
use data::user::{Field, New, User, UserRepository};
use data::{Connect, Result};
use derive_more::{Deref, DerefMut};
#[derive(Deref, DerefMut)]
pub struct Authenticated(User);
pub trait AuthenticationRepository {
fn get_user(&self, get: Get) -> impl Future<Output = Result<Option<User>>> + Send;
fn create_user(&self, new: New) -> impl Future<Output = Result<User>> + Send;
fn start_session(&self, user: User) -> impl Future<Output = Result<Authenticated>> + Send;
}
pub enum Get {
Name(Name),
Email(Email),
}
impl From<Get> for Unique {
fn from(value: Get) -> Self {
match value {
Get::Name(s) => Self::Name(s),
Get::Email(s) => Self::Email(s),
}
}
}
// Adapter
use database::connect::Connect;
use std::marker::PhantomData;
pub struct AuthenticationAdapter<D, C, UR>
@ -78,7 +52,7 @@ where
async fn start_session(&self, mut user: User) -> Result<Authenticated> {
let mut c = self.driver.open_connection().await?;
UR::update(&mut c, &mut user, Field::LastUsed(Some(Utc::now()))).await?;
UR::update(&mut c, &mut user, Field::LastUsed(Some(data::Utc::now()))).await?;
D::close_connection(c).await?;
Ok(Authenticated(user))

View File

@ -0,0 +1,122 @@
use super::Authenticated;
use data::user;
use derive_more::{Deref, Into};
use garde::Validate;
pub type Result<T = (), E = Error> = std::result::Result<T, E>;
pub trait AuthenticationContract {
fn name_available(&self, name: Name) -> impl Future<Output = Result> + Send;
fn email_available(&self, email: Email) -> impl Future<Output = Result> + Send;
fn login(&self, data: LoginData) -> impl Future<Output = Result<Authenticated>> + Send;
fn register(
&mut self,
data: RegisterData,
) -> impl Future<Output = Result<Authenticated>> + Send;
}
pub struct LoginData {
pub login: Login,
pub password: Password,
}
pub struct RegisterData {
pub name: Name,
pub email: Email,
pub password: Password,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
// Login
#[error("login was not found")]
LoginNotFound,
#[error("incorrect password")]
IncorrectPassword,
// Register
#[error("username is taken")]
NameExists,
#[error("email is already in use")]
EmailExists,
// Shared
#[error("invalid password: {0}")]
InvalidPassword(data::BoxDynError),
#[error("data source error: {0}")]
Repository(data::BoxDynError),
}
#[derive(Clone)]
pub enum Login {
Name(Name),
Email(Email),
}
impl TryFrom<String> for Login {
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
let value = match Email::try_from(value) {
Ok(x) => return Ok(Self::Email(x)),
Err((s, _)) => s,
};
match Name::try_from(value) {
Ok(x) => Ok(Self::Name(x)),
Err((s, _)) => Err((s, "login is invalid")),
}
}
}
#[derive(Clone, Deref, Into)]
pub struct Name(user::Name);
impl TryFrom<String> for Name {
type Error = (String, Box<dyn std::error::Error>);
fn try_from(value: String) -> Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
struct Username<'a>(#[garde(alphanumeric, length(chars, min = 2, max = 31))] &'a str);
if let Err(e) = Username(&value).validate() {
return Err((value, e.into()));
}
match user::Name::try_from(value) {
Ok(x) => Ok(Self(x)),
Err((s, e)) => Err((s, e.into())),
}
}
}
#[derive(Clone, Deref, Into)]
pub struct Email(user::Email);
impl TryFrom<String> for Email {
type Error = (String, Box<dyn std::error::Error>);
fn try_from(value: String) -> Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
pub struct Email<'a>(#[garde(email, length(chars, max = 255))] &'a str);
if let Err(e) = Email(&value).validate() {
return Err((value, e.into()));
}
match user::Email::try_from(value) {
Ok(x) => Ok(Self(x)),
Err((s, e)) => Err((s, e.into())),
}
}
}
#[derive(Clone, Deref, Into)]
pub struct Password(String);
impl TryFrom<String> for Password {
type Error = (String, &'static str);
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.chars().count() >= 8 {
Ok(Self(value))
} else {
Err((value, "password must be 8 characters or more"))
}
}
}

View File

@ -0,0 +1,26 @@
use data::Result;
use data::user::{Email, Name, New, Unique, User};
use derive_more::{Deref, DerefMut};
#[derive(Deref, DerefMut, Debug)]
pub struct Authenticated(pub(super) User);
pub trait AuthenticationRepository {
fn get_user(&self, get: Get) -> impl Future<Output = Result<Option<User>>> + Send;
fn create_user(&self, new: New) -> impl Future<Output = Result<User>> + Send;
fn start_session(&self, user: User) -> impl Future<Output = Result<Authenticated>> + Send;
}
pub enum Get {
Name(Name),
Email(Email),
}
impl From<Get> for Unique {
fn from(value: Get) -> Self {
match value {
Get::Name(s) => Self::Name(s),
Get::Email(s) => Self::Email(s),
}
}
}

View File

@ -0,0 +1,114 @@
use super::{
Authenticated, AuthenticationContract, AuthenticationRepository, Email, Error, Get, Login,
LoginData, Name, RegisterData, Result,
};
use argon2::{
Argon2,
password_hash::{
self, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
},
};
impl From<password_hash::Error> for Error {
fn from(error: password_hash::Error) -> Self {
match error {
password_hash::Error::Password => Self::IncorrectPassword,
_ => Self::InvalidPassword(error.into()),
}
}
}
pub struct AuthenticationService<R>
where
R: AuthenticationRepository,
{
pub(crate) repository: R,
}
impl<R> AuthenticationService<R>
where
R: AuthenticationRepository,
{
pub const fn new(repository: R) -> Self {
Self { repository }
}
}
impl<R> AuthenticationContract for AuthenticationService<R>
where
R: AuthenticationRepository + Send + Sync,
{
async fn name_available(&self, name: Name) -> Result {
if self
.repository
.get_user(Get::Name(name.into()))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::NameExists);
};
Ok(())
}
async fn email_available(&self, email: Email) -> Result {
if self
.repository
.get_user(Get::Email(email.into()))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::EmailExists);
};
Ok(())
}
async fn login(&self, data: LoginData) -> Result<Authenticated> {
let user = match data.login {
Login::Name(name) => self.repository.get_user(Get::Name(name.into())),
Login::Email(email) => self.repository.get_user(Get::Email(email.into())),
}
.await
.map_err(Error::Repository)?
.ok_or(Error::LoginNotFound)?;
Argon2::default().verify_password(
data.password.as_bytes(),
&PasswordHash::new(user.password())?,
)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
async fn register(&mut self, data: RegisterData) -> Result<Authenticated> {
self.name_available(data.name.clone()).await?;
self.email_available(data.email.clone()).await?;
// Get PHC string ($argon2id$v=19$...)
let password = Argon2::default()
.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng))?
.to_string()
.try_into()
.map_err(|(_, e)| Error::InvalidPassword(Box::from(e)))?;
let user = self
.repository
.create_user(data::user::New {
name: data.name.into(),
email: data.email.into(),
password,
last_used: None,
})
.await
.map_err(Error::Repository)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
}

View File

@ -0,0 +1,6 @@
pub mod authentication;
pub use authentication::{
Authenticated, AuthenticationAdapter, AuthenticationContract, AuthenticationRepository,
AuthenticationService,
};

View File

@ -0,0 +1,7 @@
[package]
name = "view"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.13.1", features = ["lazy", "tokio"] }

View File

@ -0,0 +1,104 @@
pub mod login;
pub mod register;
use crate::input::Validation;
use iced::Task;
pub struct Authentication {
screen: Screen,
login: login::Login,
register: register::Register,
}
pub enum Screen {
Login,
Register,
}
#[derive(Debug)]
pub enum Message {
Login(login::Message),
Register(register::Message),
}
pub enum Request {
Task(Task<Message>),
SimpleLoginValidation(login::Field),
SimpleRegisterValidation(register::Field),
Login {
login: String,
password: String,
},
Register {
name: String,
email: String,
password: String,
},
}
pub enum RequestResult {
Error(String),
LoginValidation(login::Field, Validation),
RegisterValidation(register::Field, Validation),
}
impl Default for Authentication {
fn default() -> Self {
Self::new()
}
}
impl Authentication {
pub const fn new() -> Self {
Self {
screen: Screen::Login,
login: Login::new(),
register: Register::new(),
}
}
pub fn update(&mut self, message: Message) -> Option<Request> {
Some(match message {
Message::Login(message) => match self.login.update(message)? {
login::Request::SwitchToRegister => {
self.screen = Screen::Register;
return None;
}
login::Request::SimpleValidation(x) => Request::SimpleLoginValidation(x),
login::Request::Task(task) => Request::Task(task.map(Message::Login)),
login::Request::Login { login, password } => Request::Login { login, password },
},
Message::Register(message) => match self.register.update(message)? {
register::Request::SwitchToLogin => {
self.screen = Screen::Login;
return None;
}
register::Request::SimpleValidation(x) => Request::SimpleRegisterValidation(x),
register::Request::Task(task) => Request::Task(task.map(Message::Register)),
register::Request::Register {
name,
email,
password,
} => Request::Register {
name,
email,
password,
},
},
})
}
pub fn view(&self) -> iced::Element<Message> {
match self.screen {
Screen::Login => self.login.view().map(Message::Login),
Screen::Register => self.register.view().map(Message::Register),
}
}
pub fn title(&self) -> std::borrow::Cow<str> {
match self.screen {
Screen::Login => self.login.title(),
Screen::Register => self.register.title(),
}
}
}

View File

@ -0,0 +1,164 @@
use crate::input::{Input, Validation};
use crate::widget::centerbox;
use iced::widget::{Space, button, checkbox, column, container, row, text};
use iced::{Length, Task, padding};
pub struct Login {
state: State,
login: Input,
password: Input,
show_password: bool,
}
enum State {
None,
Requesting,
Error(String),
}
pub enum Request {
SwitchToRegister,
SimpleValidation(Field),
Task(Task<Message>),
Login { login: String, password: String },
}
pub enum Field {
Login(String),
Password(String),
}
pub enum RequestResult {
Error(String),
Validation(Field, Validation),
}
#[derive(Debug, Clone)]
pub enum Message {
LoginChanged(String),
PasswordChanged(String),
ShowPasswordToggled(bool),
LoginSubmitted,
PasswordSubmitted,
LoginPressed,
RegisterPressed,
}
impl Default for Login {
fn default() -> Self {
Self::new()
}
}
impl Login {
pub const fn new() -> Self {
Self {
state: State::None,
login: Input::new("login_name"),
password: Input::new("login_password"),
show_password: false,
}
}
pub fn handle_result(&mut self, result: RequestResult) {
match result {
RequestResult::Error(e) => self.state = State::Error(e),
RequestResult::Validation(field, validation) => match &field {
Field::Login(s) => self.login.apply_if_eq(s, validation),
Field::Password(s) => self.password.apply_if_eq(s, validation),
},
}
}
pub fn update(&mut self, message: Message) -> Option<Request> {
Some(match message {
Message::LoginChanged(s) => {
self.login.update(s.clone());
Request::SimpleValidation(Field::Login(s))
}
Message::PasswordChanged(s) => {
self.password.update(s.clone());
Request::SimpleValidation(Field::Password(s))
}
Message::ShowPasswordToggled(b) => {
self.show_password = b;
return None;
}
Message::LoginSubmitted if !self.login.submittable() => return None,
Message::LoginSubmitted => Request::Task(self.login.focus()),
Message::LoginPressed | Message::PasswordSubmitted => {
if !self.login.submittable() {
Request::Task(self.login.focus())
} else if !self.password.submittable() {
Request::Task(self.password.focus())
} else {
self.state = State::Requesting;
Request::Login {
login: self.login.value().into(),
password: self.password.value().into(),
}
}
}
Message::RegisterPressed => Request::SwitchToRegister,
})
}
pub fn view(&self) -> iced::Element<Message> {
centerbox(
column![
container(text(self.title()).size(20))
.center_x(Length::Fill)
.padding(padding::bottom(10)),
self.login
.view("Email or Username")
.on_input(Message::LoginChanged)
.on_submit(Message::LoginSubmitted),
self.password
.view("Password")
.on_input(Message::PasswordChanged)
.on_submit(Message::PasswordSubmitted)
.secure(!self.show_password),
checkbox("Show password", self.show_password)
.on_toggle(Message::ShowPasswordToggled),
row![
button(text("Register").center().size(18))
.on_press(Message::RegisterPressed)
.style(button::secondary)
.width(Length::FillPortion(3))
.padding(10),
Space::with_width(Length::FillPortion(2)),
button(text("Login").center().size(18))
.on_press(Message::LoginPressed)
.style(button::primary)
.width(Length::FillPortion(3))
.padding(10)
]
.padding(padding::top(15)),
]
.width(Length::Fixed(250.))
.spacing(20),
)
}
pub fn title(&self) -> std::borrow::Cow<str> {
let errors = [
self.login.error(),
self.password.error(),
self.login.warning(),
self.password.warning(),
];
let error = errors.into_iter().flatten().next();
match &self.state {
State::None => error.map_or_else(|| "Login".into(), Into::into),
State::Requesting => "Requesting...".into(),
State::Error(e) => e.into(),
}
}
}

View File

@ -0,0 +1,216 @@
use crate::input::{Input, Validation};
use crate::widget::centerbox;
use iced::widget::{Space, button, checkbox, column, container, row, text};
use iced::{Length, Task, padding};
pub struct Register {
state: State,
name: Input,
email: Input,
password: Input,
repeat: Input,
show_password: bool,
}
enum State {
None,
Requesting,
Error(String),
}
pub enum Request {
SwitchToLogin,
SimpleValidation(Field),
Task(Task<Message>),
Register {
name: String,
email: String,
password: String,
},
}
pub enum Field {
Name(String),
Email(String),
Password(String),
}
pub enum RequestResult {
Error(String),
Validation(Field, Validation),
}
#[derive(Debug, Clone)]
pub enum Message {
NameChanged(String),
EmailChanged(String),
PasswordChanged(String),
RepeatChanged(String),
ShowPasswordToggled(bool),
EmailSubmitted,
NameSubmitted,
PasswordSubmitted,
RepeatSubmitted,
RegisterPressed,
LoginPressed,
}
impl Default for Register {
fn default() -> Self {
Self::new()
}
}
impl Register {
pub const fn new() -> Self {
Self {
state: State::None,
name: Input::new("register_name"),
email: Input::new("register_email"),
password: Input::new("register_password"),
repeat: Input::new("register_repeat"),
show_password: false,
}
}
pub fn handle_result(&mut self, result: RequestResult) {
match result {
RequestResult::Error(e) => self.state = State::Error(e),
RequestResult::Validation(field, validation) => match &field {
Field::Name(s) => self.name.apply_if_eq(s, validation),
Field::Email(s) => self.email.apply_if_eq(s, validation),
Field::Password(s) => self.password.apply_if_eq(s, validation),
},
}
}
#[inline]
fn check_passwords(&mut self) {
if self.password.value() != self.repeat.value() {
self.repeat.set_error("passwords are different".into());
}
}
pub fn update(&mut self, message: Message) -> Option<Request> {
Some(match message {
Message::NameChanged(s) => {
self.name.update(s.clone());
Request::SimpleValidation(Field::Name(s))
}
Message::EmailChanged(s) => {
self.email.update(s.clone());
Request::SimpleValidation(Field::Email(s))
}
Message::PasswordChanged(s) => {
self.password.update(s.clone());
self.check_passwords();
Request::SimpleValidation(Field::Password(s))
}
Message::RepeatChanged(s) => {
self.repeat.update(s);
self.check_passwords();
return None;
}
Message::ShowPasswordToggled(b) => {
self.show_password = b;
return None;
}
Message::NameSubmitted if !self.name.submittable() => return None,
Message::NameSubmitted => Request::Task(self.email.focus()),
Message::EmailSubmitted if !self.email.submittable() => return None,
Message::EmailSubmitted => Request::Task(self.password.focus()),
Message::PasswordSubmitted if !self.password.submittable() => return None,
Message::PasswordSubmitted => Request::Task(self.repeat.focus()),
Message::RegisterPressed | Message::RepeatSubmitted => {
if !self.name.submittable() {
Request::Task(self.name.focus())
} else if !self.email.submittable() {
Request::Task(self.email.focus())
} else if !self.password.submittable() {
Request::Task(self.password.focus())
} else if !self.repeat.submittable() {
Request::Task(self.repeat.focus())
} else {
self.state = State::Requesting;
Request::Register {
name: self.name.value().into(),
email: self.email.value().into(),
password: self.password.value().into(),
}
}
}
Message::LoginPressed => Request::SwitchToLogin,
})
}
pub fn view(&self) -> iced::Element<Message> {
centerbox(
column![
container(text(self.title()).size(20))
.center_x(Length::Fill)
.padding(padding::bottom(10)),
self.name
.view("Username")
.on_input(Message::NameChanged)
.on_submit(Message::NameSubmitted),
self.email
.view("Email")
.on_input(Message::EmailChanged)
.on_submit(Message::EmailSubmitted),
self.password
.view("Password")
.on_input(Message::PasswordChanged)
.on_submit(Message::PasswordSubmitted)
.secure(!self.show_password),
self.repeat
.view("Repeat Password")
.on_input(Message::RepeatChanged)
.on_submit(Message::RepeatSubmitted)
.secure(!self.show_password),
checkbox("Show password", self.show_password)
.on_toggle(Message::ShowPasswordToggled),
row![
button(text("Login").center().size(18))
.on_press(Message::LoginPressed)
.style(button::secondary)
.width(Length::FillPortion(3))
.padding(10),
Space::with_width(Length::FillPortion(2)),
button(text("Register").center().size(18))
.on_press(Message::RegisterPressed)
.style(button::primary)
.width(Length::FillPortion(3))
.padding(10),
]
.padding(padding::top(15)),
]
.width(Length::Fixed(250.))
.spacing(20),
)
}
pub fn title(&self) -> std::borrow::Cow<str> {
let errors = [
self.name.error(),
self.email.error(),
self.password.error(),
self.repeat.error(),
self.name.warning(),
self.email.warning(),
self.password.warning(),
self.repeat.warning(),
];
let error = errors.into_iter().flatten().next();
match &self.state {
State::None => error.map_or_else(|| "Register".into(), Into::into),
State::Requesting => "Requesting...".into(),
State::Error(e) => e.into(),
}
}
}

View File

@ -0,0 +1,93 @@
use crate::widget::text_input::{error, success, warning};
use iced::widget::{TextInput, text_input, text_input::default};
pub struct Input {
id: &'static str,
value: String,
warning: Option<String>,
state: State,
}
enum State {
None,
Valid,
Invalid(String),
}
pub enum Validation {
Valid,
Warning(String),
Invalid(String),
}
impl Input {
pub const fn new(id: &'static str) -> Self {
Self {
id,
value: String::new(),
warning: None,
state: State::None,
}
}
pub fn value(&self) -> &str {
self.value.as_ref()
}
pub fn error(&self) -> Option<&str> {
match &self.state {
State::Invalid(e) => Some(e.as_ref()),
_ => None,
}
}
pub fn warning(&self) -> Option<&str> {
self.warning.as_ref().map(AsRef::as_ref)
}
// pub fn submit(&self) -> Result<String, &str> {
// match &self.state {
// State::Invalid(e) => Err(e.as_ref()),
// State::None | State::Valid => Ok(self.value.clone()),
// }
// }
pub const fn submittable(&self) -> bool {
!matches!(self.state, State::Invalid(_))
}
pub fn update(&mut self, value: String) {
self.value = value;
self.warning = None;
self.state = State::None;
}
pub fn set_error(&mut self, value: String) {
self.state = State::Invalid(value);
}
pub fn set_warning(&mut self, value: String) {
self.warning = Some(value);
}
pub fn apply(&mut self, validation: Validation) {
match validation {
Validation::Valid => self.state = State::Valid,
Validation::Warning(w) => self.warning = Some(w),
Validation::Invalid(e) => self.state = State::Invalid(e),
}
}
pub fn apply_if_eq(&mut self, value: &str, validation: Validation) {
if self.value == value {
self.apply(validation);
}
}
pub fn focus<Message>(&self) -> iced::Task<Message> {
iced::widget::text_input::focus(self.id)
}
pub fn view<Message: Clone>(&self, placeholder: &str) -> TextInput<Message> {
text_input(placeholder, &self.value)
.id(self.id)
.padding(12)
.style(match self.state {
State::None if self.warning.is_none() => default,
State::Valid if self.warning.is_none() => success,
State::Invalid(_) => error,
_ => warning,
})
}
}

View File

@ -0,0 +1,7 @@
mod widget;
mod input;
pub use input::Validation;
pub mod authentication;
pub use authentication::{Authentication, login, register};

View File

@ -0,0 +1,71 @@
use iced::widget::{Scrollable, center, container, mouse_area, scrollable, text, tooltip};
use iced::{Element, color};
/// Put content into a dark container at the center of the screen
/// which can be scrolled in multiple dirrections
pub fn centerbox<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
center(scroll(
container(content).style(container::dark).padding(20),
))
.into()
}
/// Scrollable but in both vertical and horizontal directions
pub fn scroll<'a, Message: 'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
Scrollable::with_direction(content, scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.into()
}
/// Clickable url
pub fn url<'a, Message: Clone + 'a>(txt: &impl ToString, msg: Message) -> Element<'a, Message> {
Element::from(mouse_area(text(txt.to_string()).color(color!(0xBB_B6_DF))).on_press(msg))
}
pub mod tip {
pub use iced::widget::tooltip::Position;
}
/// Tooltip with some styling applied
pub fn tip<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
tip: &'a str,
position: tip::Position,
) -> Element<'a, Message> {
tooltip(
content,
container(text(tip).size(14))
.padding(5)
.style(container::dark),
position,
)
.into()
}
pub mod text_input {
use iced::widget::text_input::{Status, Style, default};
use iced::{Theme, color};
pub fn success(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x00_33_00).into(),
..default(theme, status)
}
}
pub fn warning(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x33_33_00).into(),
..default(theme, status)
}
}
pub fn error(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x33_00_00).into(),
..default(theme, status)
}
}
}