Reorganize & implement Authentication view api
This commit is contained in:
1
3/coursework/src/.gitignore
vendored
1
3/coursework/src/.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
/target/
|
||||
/**/scrapyard/
|
||||
/database/data/
|
||||
|
45
3/coursework/src/Cargo.lock
generated
45
3/coursework/src/Cargo.lock
generated
@ -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"
|
||||
|
@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["app/*", "libs/*", "database"]
|
||||
members = ["data", "main", "service", "view"]
|
||||
|
@ -1,4 +0,0 @@
|
||||
// use database::port::user;
|
||||
|
||||
mod repository;
|
||||
mod service;
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "database"
|
||||
name = "data"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
2
3/coursework/src/data/src/adapter.rs
Normal file
2
3/coursework/src/data/src/adapter.rs
Normal file
@ -0,0 +1,2 @@
|
||||
//! Specific implementations of [`crate::port`]s to plug into other parts of the application.
|
||||
pub mod mysql;
|
@ -1,3 +1,4 @@
|
||||
//! `MySQL` adapters.
|
||||
pub mod base;
|
||||
pub mod package;
|
||||
pub mod user;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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?;
|
21
3/coursework/src/data/src/lib.rs
Normal file
21
3/coursework/src/data/src/lib.rs
Normal 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};
|
@ -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;
|
@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
@ -1 +0,0 @@
|
||||
pub mod mysql;
|
@ -1,4 +0,0 @@
|
||||
pub mod adapter;
|
||||
pub mod atomic;
|
||||
pub mod connect;
|
||||
pub mod port;
|
@ -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"
|
9
3/coursework/src/service/src/authentication.rs
Normal file
9
3/coursework/src/service/src/authentication.rs
Normal 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::*;
|
@ -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))
|
122
3/coursework/src/service/src/authentication/contract.rs
Normal file
122
3/coursework/src/service/src/authentication/contract.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
26
3/coursework/src/service/src/authentication/repository.rs
Normal file
26
3/coursework/src/service/src/authentication/repository.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
114
3/coursework/src/service/src/authentication/service.rs
Normal file
114
3/coursework/src/service/src/authentication/service.rs
Normal 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)
|
||||
}
|
||||
}
|
6
3/coursework/src/service/src/lib.rs
Normal file
6
3/coursework/src/service/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod authentication;
|
||||
|
||||
pub use authentication::{
|
||||
Authenticated, AuthenticationAdapter, AuthenticationContract, AuthenticationRepository,
|
||||
AuthenticationService,
|
||||
};
|
7
3/coursework/src/view/Cargo.toml
Normal file
7
3/coursework/src/view/Cargo.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "view"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
iced = { version = "0.13.1", features = ["lazy", "tokio"] }
|
104
3/coursework/src/view/src/authentication.rs
Normal file
104
3/coursework/src/view/src/authentication.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
164
3/coursework/src/view/src/authentication/login.rs
Normal file
164
3/coursework/src/view/src/authentication/login.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
216
3/coursework/src/view/src/authentication/register.rs
Normal file
216
3/coursework/src/view/src/authentication/register.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
93
3/coursework/src/view/src/input.rs
Normal file
93
3/coursework/src/view/src/input.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
7
3/coursework/src/view/src/lib.rs
Normal file
7
3/coursework/src/view/src/lib.rs
Normal file
@ -0,0 +1,7 @@
|
||||
mod widget;
|
||||
|
||||
mod input;
|
||||
pub use input::Validation;
|
||||
|
||||
pub mod authentication;
|
||||
pub use authentication::{Authentication, login, register};
|
71
3/coursework/src/view/src/widget.rs
Normal file
71
3/coursework/src/view/src/widget.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user