4 changes: 4 additions & 0 deletions PasswordManager/PasswordManager.csproj
Expand Up @@ -57,6 +57,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Maui" Version="5.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.1.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
<PackageReference Include="Realm" Version="10.21.1" />
Expand All @@ -71,6 +72,9 @@
</ItemGroup>

<ItemGroup>
<MauiXaml Update="Resources\Styles\Converters.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="View\AddPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
Expand Down
3 changes: 3 additions & 0 deletions PasswordManager/PasswordManager.csproj.user
Expand Up @@ -33,6 +33,9 @@
<MauiXaml Update="Resources\Styles\Colors.xaml">
<SubType>Designer</SubType>
</MauiXaml>
<MauiXaml Update="Resources\Styles\Converters.xaml">
<SubType>Designer</SubType>
</MauiXaml>
<MauiXaml Update="Resources\Styles\Styles.xaml">
<SubType>Designer</SubType>
</MauiXaml>
Expand Down
3 changes: 3 additions & 0 deletions PasswordManager/Resources/Styles/Colors.xaml
Expand Up @@ -41,4 +41,7 @@
<Color x:Key="Blue200Accent">#72ACF1</Color>
<Color x:Key="Blue300Accent">#A7CBF6</Color>

<Color x:Key="LightErrorColor">#A0FF0000</Color>
<Color x:Key="DarkErrorColor">#DDB00000</Color>

</ResourceDictionary>
6 changes: 6 additions & 0 deletions PasswordManager/Resources/Styles/Converters.xaml
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PasswordManager.Resources.Styles.Converters">

</ResourceDictionary>
14 changes: 14 additions & 0 deletions PasswordManager/Resources/Styles/Converters.xaml.cs
@@ -0,0 +1,14 @@
using PasswordManager.Model.Converter;

namespace PasswordManager.Resources.Styles;

public partial class Converters : ResourceDictionary
{
public Converters()
{
InitializeComponent();

//If initialized in xaml throws an exception
Add("FirstValidationConverter", new FirstValidationConverter());
}
}
21 changes: 21 additions & 0 deletions PasswordManager/Resources/Styles/Styles.xaml
Expand Up @@ -401,5 +401,26 @@
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>

<Style x:Key="ErrorLabel" TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource LightErrorColor}, Dark={StaticResource DarkErrorColor}}"/>
<Setter Property="FontSize" Value="Small"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>

<Style x:Key="EntryWithValidation" TargetType="Entry">
<Setter Property="Text" Value="{Binding Source={RelativeSource Self}, Path=BindingContext.Value}"/>
<Setter Property="HorizontalOptions" Value="Fill"/>
<Setter Property="MinimumWidthRequest" Value="150"/>
<Setter Property="Margin" Value="5,0,0,0"/>
<Setter Property="FontSize" Value="Medium"/>
<Style.Triggers>
<DataTrigger
TargetType="Entry"
Binding="{Binding Source={RelativeSource Self}, Path=BindingContext.IsValid}"
Value="False">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource LightErrorColor}, Dark={StaticResource DarkErrorColor}}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
25 changes: 0 additions & 25 deletions PasswordManager/Utils/ProfileHelper.cs

This file was deleted.

17 changes: 17 additions & 0 deletions PasswordManager/Utils/ThrowHelper.cs
@@ -0,0 +1,17 @@
namespace PasswordManager.Utils
{
internal static class ThrowHelper
{
internal static T ThrowNotSupportedException<T>() => (T)ThrowNotSupportedException(typeof(T));

internal static object ThrowNotSupportedException(Type conversionType)
{
throw new NotSupportedException($"Converting to type {conversionType} is not supported");
}

internal static object ThrowNotSupportedException(string message = null)
{
throw new NotSupportedException(message);
}
}
}
15 changes: 15 additions & 0 deletions PasswordManager/Validation/IValidity.cs
@@ -0,0 +1,15 @@
namespace PasswordManager.Validation
{
public interface IValidity
{
/// <summary>
/// Defines if object is valid
/// </summary>
bool IsValid { get; set; }

/// <summary>
/// Validates object
/// </summary>
public bool Validate();
}
}
8 changes: 8 additions & 0 deletions PasswordManager/Validation/Rules/IValidationRule.cs
@@ -0,0 +1,8 @@
namespace PasswordManager.Validation.Rules
{
public interface IValidationRule<T>
{
string ValidationMessage { get; set; }
bool Check(T value);
}
}
9 changes: 9 additions & 0 deletions PasswordManager/Validation/Rules/IsNotNullOrEmptyRule.cs
@@ -0,0 +1,9 @@
namespace PasswordManager.Validation.Rules
{
public class IsNotNullOrEmptyRule : IValidationRule<string>
{
public string ValidationMessage { get; set; }

public bool Check(string value) => !String.IsNullOrEmpty(value);
}
}
9 changes: 9 additions & 0 deletions PasswordManager/Validation/Rules/IsNotNullRule.cs
@@ -0,0 +1,9 @@
namespace PasswordManager.Validation.Rules
{
public class IsNotNullRule<T> : IValidationRule<T>
{
public string ValidationMessage { get; set; }

public bool Check(T value) => value is not null;
}
}
16 changes: 16 additions & 0 deletions PasswordManager/Validation/Rules/LoginPasswordRule.cs
@@ -0,0 +1,16 @@
namespace PasswordManager.Validation.Rules
{
public class LoginPasswordRule : IValidationRule<string>
{
private readonly ISecureStorage secureStorage;

public string ValidationMessage { get; set; }

public LoginPasswordRule(ISecureStorage secureStorage)
{
this.secureStorage = secureStorage;
}

public bool Check(string value) => secureStorage.GetAsync("app-password").Result == value;
}
}
11 changes: 11 additions & 0 deletions PasswordManager/Validation/Rules/PasswordLengthRule.cs
@@ -0,0 +1,11 @@
namespace PasswordManager.Validation.Rules
{
public class PasswordLengthRule : IValidationRule<string>
{
private const int MIN_PASSWORD_LENGTH = 8;

public string ValidationMessage { get; set; } = $"Password must be at least {MIN_PASSWORD_LENGTH} characters long";

public bool Check(string value) => value.Length >= 8;
}
}
9 changes: 9 additions & 0 deletions PasswordManager/Validation/Rules/PasswordsMatchRule.cs
@@ -0,0 +1,9 @@
namespace PasswordManager.Validation.Rules
{
public class PasswordsMatchRule : IValidationRule<(string, string)>
{
public string ValidationMessage { get; set; } = "Passwords does not match";

public bool Check((string, string) value) => value.Item1 == value.Item2;
}
}
44 changes: 44 additions & 0 deletions PasswordManager/Validation/ValidatableObject.cs
@@ -0,0 +1,44 @@
using CommunityToolkit.Mvvm.ComponentModel;
using PasswordManager.Validation.Rules;
using System.Windows.Input;

namespace PasswordManager.Validation
{
/// <summary>
/// Validates <see cref="Value"/> based on <see cref="Validations"/> rules
/// </summary>
public partial class ValidatableObject<T> : ObservableObject, IValidity
{
[ObservableProperty]
private IEnumerable<string> _errors;

[ObservableProperty]
private bool _isValid;

[ObservableProperty]
private T _value = default;

public ICommand ValidateCommand { get; }

public List<IValidationRule<T>> Validations { get; } = new();

public ValidatableObject()
{
IsValid = true;
Errors = Enumerable.Empty<string>();
ValidateCommand = new Command(() => Validate());
}

public bool Validate()
{
Errors = Validations
?.Where(v => !v.Check(Value))
?.Select(v => v.ValidationMessage)
?.ToArray()
?? Enumerable.Empty<string>();

IsValid = !Errors.Any();
return IsValid;
}
}
}
30 changes: 26 additions & 4 deletions PasswordManager/View/AddPage.xaml
Expand Up @@ -4,21 +4,43 @@
x:Class="PasswordManager.View.AddPage"
xmlns:viewmodel="clr-namespace:PasswordManager.ViewModel"
xmlns:schema="clr-namespace:PasswordManager.Model.DB.Schema"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:PasswordManager.Model.Converter"
x:DataType="viewmodel:AddViewModel"
Title="Add new password">
x:Name="Addpage"
Title="Add new password">

<VerticalStackLayout>
<HorizontalStackLayout HorizontalOptions="CenterAndExpand">
<Label Text="Service:" VerticalOptions="Center" FontSize="Medium"/>
<Picker ItemsSource="{Binding Services}" SelectedItem="{Binding SelectedService}" HorizontalOptions="Fill" MinimumWidthRequest="150" Margin="5,0,0,0" FontSize="Medium" ItemDisplayBinding="{Binding Name}"/>
<Picker ItemsSource="{Binding Services}" SelectedItem="{Binding SelectedService.Value}" HorizontalOptions="Fill" MinimumWidthRequest="150" Margin="5,0,0,0" FontSize="Medium" ItemDisplayBinding="{Binding Name}"/>
</HorizontalStackLayout>
<Label Text="{Binding SelectedService.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}" HorizontalOptions="Center"/>

<HorizontalStackLayout HorizontalOptions="CenterAndExpand">
<Label Text="Login:" VerticalOptions="Center" FontSize="Medium"/>
<Entry Text="{Binding Username}" HorizontalOptions="Fill" MinimumWidthRequest="150" Margin="5,0,0,0" FontSize="Medium"/>
<Entry BindingContext="{Binding Username}" Style="{StaticResource EntryWithValidation}">
<Entry.Behaviors>
<toolkit:EventToCommandBehavior
Command="{Binding Source={x:Reference Addpage}, Path=BindingContext.Username.ValidateCommand}"
EventName="TextChanged"/>
</Entry.Behaviors>
</Entry>
</HorizontalStackLayout>
<Label Text="{Binding Username.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}" HorizontalOptions="Center"/>

<HorizontalStackLayout HorizontalOptions="CenterAndExpand">
<Label Text="Password:" VerticalOptions="Center" FontSize="Medium"/>
<Entry Text="{Binding Password}" HorizontalOptions="Fill" MinimumWidthRequest="150" Margin="5,0,0,0" FontSize="Medium" IsPassword="True"/>
<Entry BindingContext="{Binding Password}" Style="{StaticResource EntryWithValidation}" IsPassword="True">
<Entry.Behaviors>
<toolkit:EventToCommandBehavior
Command="{Binding Source={x:Reference Addpage}, Path=BindingContext.Password.ValidateCommand}"
EventName="TextChanged"/>
</Entry.Behaviors>
</Entry>
</HorizontalStackLayout>
<Label Text="{Binding Password.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}" HorizontalOptions="Center"/>

<Button Text="Add" VerticalOptions="CenterAndExpand" Command="{Binding AddProfileCommand}"/>
</VerticalStackLayout>
</ContentPage>
5 changes: 4 additions & 1 deletion PasswordManager/View/LoginPage.xaml
Expand Up @@ -3,10 +3,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PasswordManager.View.LoginPage"
xmlns:viewmodel="clr-namespace:PasswordManager.ViewModel"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="viewmodel:LoginViewModel"
x:Name="Loginpage"
Title="Login">
<VerticalStackLayout>
<Entry Text="{Binding Password}" IsPassword="True"/>
<Entry BindingContext="{Binding Password}" IsPassword="True" Style="{StaticResource EntryWithValidation}"/>
<Label Text="{Binding Password.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}"/>
<Button Text="Login" Command="{Binding LoginCommand}"/>
</VerticalStackLayout>
</ContentPage>
25 changes: 22 additions & 3 deletions PasswordManager/View/RegisterPage.xaml
Expand Up @@ -3,13 +3,32 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PasswordManager.View.RegisterPage"
xmlns:viewmodel="clr-namespace:PasswordManager.ViewModel"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:PasswordManager.Model.Converter"
x:DataType="viewmodel:RegisterViewModel"
Title="Registeration">
x:Name="Registration"
Title="Registration">
<VerticalStackLayout>
<Label Text="Enter password"/>
<Entry Text="{Binding Password}" IsPassword="True"/>
<Entry BindingContext="{Binding Password}" Style="{StaticResource EntryWithValidation}" IsPassword="True">
<Entry.Behaviors>
<toolkit:EventToCommandBehavior
Command="{Binding Source={x:Reference Registration}, Path=BindingContext.Password.ValidateCommand}"
EventName="TextChanged"/>
</Entry.Behaviors>
</Entry>
<Label Text="{Binding Password.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}"/>

<Label Text="Confirm password"/>
<Entry Text="{Binding PasswordConfirmation}" IsPassword="True"/>
<Entry BindingContext="{Binding MatchValidation}" Text="{Binding Source={x:Reference Registration}, Path=BindingContext.PasswordConfirmation.Value}" IsPassword="True">
<Entry.Behaviors>
<toolkit:EventToCommandBehavior
Command="{Binding Source={x:Reference Registration}, Path=BindingContext.MatchValidation.ValidateCommand}"
EventName="TextChanged"/>
</Entry.Behaviors>
</Entry>
<Label Text="{Binding MatchValidation.Errors, Converter={StaticResource FirstValidationConverter}}" Style="{StaticResource ErrorLabel}"/>

<Button Text="Register" Command="{Binding RegisterCommand}"/>
</VerticalStackLayout>
</ContentPage>
2 changes: 1 addition & 1 deletion PasswordManager/View/RegisterPage.xaml.cs
Expand Up @@ -6,7 +6,7 @@ public partial class RegisterPage : ContentPage
{
public RegisterPage(RegisterViewModel vm)
{
InitializeComponent();
BindingContext = vm;
InitializeComponent();
}
}
2 changes: 1 addition & 1 deletion PasswordManager/View/SettingsPage.xaml.cs
Expand Up @@ -6,7 +6,7 @@ public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel vm)
{
InitializeComponent();
BindingContext = vm;
InitializeComponent();
}
}
100 changes: 80 additions & 20 deletions PasswordManager/ViewModel/AddViewModel.cs
Expand Up @@ -2,7 +2,8 @@
using CommunityToolkit.Mvvm.Input;
using PasswordManager.Model.DB.Schema;
using PasswordManager.Services;
using PasswordManager.Utils;
using PasswordManager.Validation;
using PasswordManager.Validation.Rules;

namespace PasswordManager.ViewModel
{
Expand All @@ -14,47 +15,106 @@ public partial class AddViewModel : ObservableObject
[ObservableProperty]
private IQueryable<ServiceInfo> services;

[ObservableProperty]
private string username;
public ValidatableObject<string> Username { get; } = new();

[ObservableProperty]
private string password;
public ValidatableObject<string> Password { get; } = new();

[ObservableProperty]
private ServiceInfo selectedService;
public ValidatableObject<ServiceInfo> SelectedService { get; } = new();

public AddViewModel(DatabaseService databaseService, INavigationService navigationService)
{
_databaseService = databaseService;
_navigationService = navigationService;

Services = databaseService.Select<ServiceInfo>();
SelectedService = Services.First() ?? ServiceInfo.DefaultServices.FirstOrDefault();
SelectedService.Value = Services.First() ?? ServiceInfo.DefaultServices.FirstOrDefault();

AddValidation();
}

private void AddValidation()
{
Username.Validations.Add(new IsNotNullOrEmptyRule()
{
ValidationMessage = "A username is required"

/* Unmerged change from project 'PasswordManager (net7.0-maccatalyst)'
Before:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
After:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
*/

/* Unmerged change from project 'PasswordManager (net7.0-ios)'
Before:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
After:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
*/

/* Unmerged change from project 'PasswordManager (net7.0)'
Before:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
After:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
*/

/* Unmerged change from project 'PasswordManager (net7.0-android)'
Before:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
After:
});
Password.Validations.Add(new IsNotNullOrEmptyRule()
*/
});

Password.Validations.Add(new IsNotNullOrEmptyRule()
{
ValidationMessage = "A password is required"
});

SelectedService.Validations.Add(new IsNotNullRule<ServiceInfo>()
{
ValidationMessage = "A service is required"
});
}

[RelayCommand]
async Task AddProfile()
{
ProfileInfo profile = new ProfileInfo()
if (Username.Validate() && Password.Validate() && SelectedService.Validate())
{
Username = Username,
Password = Password,
Service = SelectedService
};
ProfileInfo profile = new()
{
Username = Username.Value,
Password = Password.Value,
Service = SelectedService.Value
};

try
{
_databaseService.Add(profile.Verify());
}
catch { }
_databaseService.Add(profile);

await GoBack();
await GoBack();
}
}

async Task GoBack()
{
await _navigationService.PopAsync();
}

}
}
24 changes: 15 additions & 9 deletions PasswordManager/ViewModel/LoginViewModel.cs
@@ -1,39 +1,45 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PasswordManager.Services;
using PasswordManager.Validation;
using PasswordManager.Validation.Rules;
using PasswordManager.View;
using SharpHook;

namespace PasswordManager.ViewModel
{
public partial class LoginViewModel : ObservableObject
{
private ISecureStorage secureStorage;
private INavigationService navigationService;
private IGlobalHook hook;
private readonly INavigationService navigationService;
private readonly IGlobalHook hook;

[ObservableProperty]
private string password;
public ValidatableObject<string> Password { get; set; } = new();

public LoginViewModel(ISecureStorage secureStorage, INavigationService navigation, IGlobalHook globalHook)
{
this.secureStorage = secureStorage;
navigationService = navigation;
hook = globalHook;

hook.KeyPressed += OnKeyPressed;

Password.Validations.Add(new LoginPasswordRule(secureStorage)
{
ValidationMessage = "Incorrect password"
});
}

private void OnKeyPressed(object sender, KeyboardHookEventArgs e)
{
if (e.Data.KeyCode == SharpHook.Native.KeyCode.VcEnter)
MainThread.BeginInvokeOnMainThread(Login);
MainThread.InvokeOnMainThreadAsync(Login);
}

[RelayCommand]
async void Login()
async Task Login()
{
if (Password == await secureStorage.GetAsync("app-password"))
await Task.Run(Password.Validate);

if (Password.IsValid)
{
await navigationService.NavigateToAsync($"//{nameof(RecentPage)}");

Expand Down
52 changes: 42 additions & 10 deletions PasswordManager/ViewModel/RegisterViewModel.cs
@@ -1,22 +1,24 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PasswordManager.Services;
using PasswordManager.Validation;
using PasswordManager.Validation.Rules;
using PasswordManager.View;
using SharpHook;
using System.ComponentModel;

namespace PasswordManager.ViewModel
{
public partial class RegisterViewModel : ObservableObject
{
private ISecureStorage secureStorage;
private INavigationService navigationService;
private IGlobalHook hook;
private readonly ISecureStorage secureStorage;
private readonly INavigationService navigationService;
private readonly IGlobalHook hook;

[ObservableProperty]
private string password;
public ValidatableObject<(string, string)> MatchValidation { get; } = new();

[ObservableProperty]
private string passwordConfirmation;
public ValidatableObject<string> Password { get; } = new();
public ValidatableObject<string> PasswordConfirmation { get; } = new();

public RegisterViewModel(ISecureStorage secureStorage, INavigationService navigationService, IGlobalHook hook)
{
Expand All @@ -25,6 +27,36 @@ public RegisterViewModel(ISecureStorage secureStorage, INavigationService naviga
this.hook = hook;

hook.KeyPressed += OnKeyPressed;

AddValidations();
}

private void AddValidations()
{
AddPasswordValidations(Password);
AddPasswordValidations(PasswordConfirmation);

MatchValidation.Value = (Password.Value, PasswordConfirmation.Value);
MatchValidation.Validations.Add(new PasswordsMatchRule());

Password.PropertyChanged += onPasswordPropertyChanged;
PasswordConfirmation.PropertyChanged += onPasswordPropertyChanged;

void AddPasswordValidations(ValidatableObject<string> validatableObject)
{
validatableObject.Validations.Add(new IsNotNullOrEmptyRule()
{
ValidationMessage = "A password is required"
});

validatableObject.Validations.Add(new PasswordLengthRule());
}

void onPasswordPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Password.Value) || e.PropertyName == nameof(PasswordConfirmation.Value))
MatchValidation.Value = (Password.Value, PasswordConfirmation.Value);
}
}

private void OnKeyPressed(object sender, KeyboardHookEventArgs e)
Expand All @@ -36,9 +68,9 @@ private void OnKeyPressed(object sender, KeyboardHookEventArgs e)
[RelayCommand]
async void Register()
{
if (Password == PasswordConfirmation && Password.Length >= 8)
if (Password.Validate() && PasswordConfirmation.Validate() && MatchValidation.Validate())
{
await secureStorage.SetAsync("app-password", Password);
await secureStorage.SetAsync("app-password", Password.Value);

await navigationService.NavigateToAsync($"//{nameof(RecentPage)}");

Expand All @@ -52,4 +84,4 @@ async void Register()
}
}
}
}
}