diff --git a/GUI/WPF/App.xaml b/GUI/WPF/App.xaml new file mode 100644 index 0000000..68680e1 --- /dev/null +++ b/GUI/WPF/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/GUI/WPF/App.xaml.cs b/GUI/WPF/App.xaml.cs new file mode 100644 index 0000000..fd5122b --- /dev/null +++ b/GUI/WPF/App.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows; + +namespace Upsilon.Apps.Passkey.GUI.WPF +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + } + +} diff --git a/GUI/WPF/AssemblyInfo.cs b/GUI/WPF/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/GUI/WPF/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/GUI/WPF/Helper/EnumHelper.cs b/GUI/WPF/Helper/EnumHelper.cs new file mode 100644 index 0000000..025cd91 --- /dev/null +++ b/GUI/WPF/Helper/EnumHelper.cs @@ -0,0 +1,74 @@ +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces.Enums; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + internal static class EnumHelper + { + public static string ToReadableString(this ActivityEventType eventType) + { + return eventType switch + { + ActivityEventType.None => "All", + ActivityEventType.MergeAndSaveThenRemoveAutoSaveFile => "Auto-save merged then saved", + ActivityEventType.MergeWithoutSavingAndKeepAutoSaveFile => "Auto-save merged but not saved", + ActivityEventType.DontMergeAndRemoveAutoSaveFile => "Auto-save discarded", + ActivityEventType.DontMergeAndKeepAutoSaveFile => "Auto-save not merged and keeped", + ActivityEventType.DatabaseCreated + or ActivityEventType.DatabaseOpened + or ActivityEventType.DatabaseSaved + or ActivityEventType.DatabaseClosed + or ActivityEventType.LoginSessionTimeoutReached + or ActivityEventType.LoginFailed + or ActivityEventType.UserLoggedIn + or ActivityEventType.UserLoggedOut + or ActivityEventType.ImportingDataStarted + or ActivityEventType.ImportingDataSucceded + or ActivityEventType.ImportingDataFailed + or ActivityEventType.ExportingDataStarted + or ActivityEventType.ExportingDataSucceded + or ActivityEventType.ExportingDataFailed + or ActivityEventType.ItemUpdated + or ActivityEventType.ItemAdded + or ActivityEventType.ItemDeleted => eventType.ToString().ToSentenceCase(), + _ => throw new InvalidOperationException($"'{eventType}' event type not handled"), + }; + } + + public static ActivityEventType ActivityEventTypeFromReadableString(string readableString) + { + try + { + return Enum.GetValues() + .Cast() + .First(x => x.ToReadableString() == readableString); + } + catch + { + throw new InvalidOperationException($"'{readableString}' event type not handled"); + } + } + + public static string ToReadableString(this WarningType warningType) + { + return warningType switch + { + WarningType.PasswordUpdateReminderWarning | WarningType.PasswordLeakedWarning => "All", + WarningType.PasswordUpdateReminderWarning => "Expired passwords", + WarningType.PasswordLeakedWarning => "Leaked passwords", + _ => throw new InvalidOperationException($"'{warningType}' warning type not handled"), + }; + } + + public static WarningType ActivityWarningTypeFromReadableString(string readableString) + { + return readableString switch + { + "All" => WarningType.PasswordUpdateReminderWarning | WarningType.PasswordLeakedWarning, + "Expired passwords" => WarningType.PasswordUpdateReminderWarning, + "Leaked passwords" => WarningType.PasswordLeakedWarning, + _ => throw new InvalidOperationException($"'{readableString}' warning type not handled"), + }; + } + } +} diff --git a/GUI/WPF/Helper/HotKeyHelper.cs b/GUI/WPF/Helper/HotKeyHelper.cs new file mode 100644 index 0000000..b6582d0 --- /dev/null +++ b/GUI/WPF/Helper/HotKeyHelper.cs @@ -0,0 +1,92 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Input; +using System.Windows.Interop; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + public static class HotkeyHelper + { + private static int _id = 0; + + public static event EventHandler? HotkeyPressed; + + public static int Register(Window window, ModifierKeys modifiers, Key key) + { + int hotkeyId = Interlocked.Increment(ref _id); + uint virtualKey = (uint)KeyInterop.VirtualKeyFromKey(key); + + IntPtr hWnd = new WindowInteropHelper(window).Handle; + if (hWnd == IntPtr.Zero) + return -1; + + bool success = RegisterHotKey(hWnd, hotkeyId, (uint)modifiers, virtualKey); + if (!success) + return -1; + + if (PresentationSource.FromVisual(window) is HwndSource source) + { + source.AddHook((hwnd, msg, wParam, lParam, ref handled) => + { + if (msg == 0x0312 && (int)wParam == hotkeyId) + { + HotkeyEventArgs e = new(lParam); + HotkeyPressed?.Invoke(window, e); + handled = true; + } + return IntPtr.Zero; + }); + } + + return hotkeyId; + } + + public static bool Unregister(Window window, int hotkeyId) + { + if (window is null) + return false; + + IntPtr hWnd = new WindowInteropHelper(window).Handle; + return hWnd != nint.Zero && UnregisterHotKey(hWnd, hotkeyId); + } + + public static void Send(ModifierKeys modifiers, Key key) + { + //byte[] modifiers = []; + byte virtualKey = (byte)KeyInterop.VirtualKeyFromKey(key); + + keybd_event((byte)modifiers, 0, 0, UIntPtr.Zero); + + keybd_event(virtualKey, 0, 0, UIntPtr.Zero); + keybd_event(virtualKey, 0, 0x0002, UIntPtr.Zero); + + keybd_event((byte)modifiers, 0, 0x0002, UIntPtr.Zero); + } + + [DllImport("user32.dll")] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll")] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + [DllImport("user32.dll")] + private static extern uint MapVirtualKey(uint uCode, uint uMapType); + + [DllImport("user32.dll")] + private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + } + + public class HotkeyEventArgs : EventArgs + { + public readonly Key Key; + public readonly ModifierKeys Modifiers; + + internal HotkeyEventArgs(IntPtr hotKeyParam) + { + uint param = (uint)hotKeyParam.ToInt64(); + int virtualKey = (int)((param & 0xffff0000) >> 16); + Key = KeyInterop.KeyFromVirtualKey(virtualKey); + Modifiers = (ModifierKeys)(param & 0x0000ffff); + } + } +} \ No newline at end of file diff --git a/GUI/WPF/Helper/IItemHelper.cs b/GUI/WPF/Helper/IItemHelper.cs new file mode 100644 index 0000000..d36ecd8 --- /dev/null +++ b/GUI/WPF/Helper/IItemHelper.cs @@ -0,0 +1,57 @@ +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + public static class IItemHelper + { + public static void Shake(this IUser user) + { + _ = user.ItemId; + } + + public static bool MeetsFilterConditions(this IService service, string serviceFilter, string identifierFilter, string globalTextFilter, bool changedItemsOnly) + { + serviceFilter = serviceFilter.ToLower().Trim(); + identifierFilter = identifierFilter.ToLower().Trim(); + globalTextFilter = globalTextFilter.ToLower().Trim(); + + string serviceId = service.ItemId.Replace(service.User.ItemId, string.Empty).ToLower().Trim(); + string serviceName = service.ServiceName.ToLower().Trim(); + string url = service.Url.ToLower().Trim(); + string notes = service.Notes.ToLower().Trim(); + + return (!string.IsNullOrWhiteSpace(globalTextFilter) + ? serviceId == globalTextFilter + || serviceName.Contains(globalTextFilter) + || url.Contains(globalTextFilter) + || notes.Contains(globalTextFilter) + || service.Accounts.Any(x => x.MeetsFilterConditions(string.Empty, globalTextFilter, changedItemsOnly)) + : (string.IsNullOrWhiteSpace(serviceFilter) + || (!string.IsNullOrWhiteSpace(serviceFilter) && serviceName.Contains(serviceFilter))) + && (string.IsNullOrWhiteSpace(identifierFilter) + || service.Accounts.Any(x => x.MeetsFilterConditions(identifierFilter, globalTextFilter, changedItemsOnly)))) + && (!changedItemsOnly || service.HasChanged()); + } + + public static bool MeetsFilterConditions(this IAccount account, string identifierFilter, string globalTextFilter, bool changedItemsOnly) + { + identifierFilter = identifierFilter.ToLower().Trim(); + globalTextFilter = globalTextFilter.ToLower().Trim(); + + string accountId = account.ItemId.Replace(account.Service.ItemId, string.Empty).ToLower().Trim(); + string label = account.Label.ToLower().Trim(); + string notes = account.Notes.ToLower().Trim(); + string identifiers = string.Join("\n", account.Identifiers.Select(x => x.ToLower().Trim())); + + return (!string.IsNullOrWhiteSpace(globalTextFilter) + ? accountId == globalTextFilter + || identifiers.Contains(globalTextFilter) + || label.ToLower().Contains(globalTextFilter) + || notes.ToLower().Contains(globalTextFilter) + : string.IsNullOrWhiteSpace(identifierFilter) + || identifiers.Contains(identifierFilter) + || label.Contains(identifierFilter)) + && (!changedItemsOnly || account.HasChanged()); + } + } +} diff --git a/GUI/WPF/Helper/NumericTextBoxHelper.cs b/GUI/WPF/Helper/NumericTextBoxHelper.cs new file mode 100644 index 0000000..824aae4 --- /dev/null +++ b/GUI/WPF/Helper/NumericTextBoxHelper.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + public static class NumericTextBoxHelper + { + private static readonly Regex _regex = new("[^0-9]+"); //regex that matches disallowed text + + private static bool _isTextAllowed(string text) + { + bool isValid = !_regex.IsMatch(text); + + if (isValid) + { + isValid = int.TryParse(text, out _); + } + + return isValid; + } + + public static void TextChanged(object sender, TextChangedEventArgs e) + { + TextBox textBox = (TextBox)sender; + + e.Handled = !_isTextAllowed(textBox.Text); + + if (e.Handled) + { + textBox.Text = textBox.Text.Replace(" ", ""); + } + } + + public static void PreviewTextInput(object sender, TextCompositionEventArgs e) + { + e.Handled = !_isTextAllowed(e.Text); + } + + public static void Pasting(object sender, DataObjectPastingEventArgs e) + { + if (e.DataObject.GetDataPresent(typeof(string))) + { + string text = (string)e.DataObject.GetData(typeof(string)); + if (!_isTextAllowed(text)) + { + e.CancelCommand(); + } + } + else + { + e.CancelCommand(); + } + } + } +} diff --git a/GUI/WPF/Helper/PropertyHelper.cs b/GUI/WPF/Helper/PropertyHelper.cs new file mode 100644 index 0000000..a8b88c9 --- /dev/null +++ b/GUI/WPF/Helper/PropertyHelper.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + public static class PropertyHelper + { + public static bool SetProperty(ref T field, + T newValue, + INotifyPropertyChanged sender, + PropertyChangedEventHandler? PropertyChanged, + [CallerMemberName] string? propertyName = null) + { + if (!Equals(field, newValue)) + { + field = newValue; + PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(propertyName)); + + return true; + } + + return false; + } + } +} diff --git a/GUI/WPF/Helper/WindowHelper.cs b/GUI/WPF/Helper/WindowHelper.cs new file mode 100644 index 0000000..19995ce --- /dev/null +++ b/GUI/WPF/Helper/WindowHelper.cs @@ -0,0 +1,24 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + public static class WindowHelper + { + public static bool GetIsBusy(this Window window) + { + return window.Cursor == Cursors.Wait; + } + + public static void SetIsBusy(this Window window, bool isBusy) + { + window.Cursor = isBusy ? Cursors.Wait : Cursors.Arrow; + } + + public static bool GetIsBusy(this UserControl control) + { + return Window.GetWindow(control).GetIsBusy(); + } + } +} diff --git a/GUI/WPF/MainWindow.xaml b/GUI/WPF/MainWindow.xaml new file mode 100644 index 0000000..8210e4f --- /dev/null +++ b/GUI/WPF/MainWindow.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GUI/WPF/MainWindow.xaml.cs b/GUI/WPF/MainWindow.xaml.cs new file mode 100644 index 0000000..02c139a --- /dev/null +++ b/GUI/WPF/MainWindow.xaml.cs @@ -0,0 +1,212 @@ +using Microsoft.Win32; +using System.IO; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.GUI.WPF.Themes; +using Upsilon.Apps.Passkey.GUI.WPF.ViewModels; +using Upsilon.Apps.Passkey.GUI.WPF.Views; + +namespace Upsilon.Apps.Passkey.GUI.WPF +{ + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow : Window + { + private readonly MainViewModel _mainViewModel; + private readonly DispatcherTimer _timer; + + public MainWindow() + { + InitializeComponent(); + + DataContext = _mainViewModel = new MainViewModel(); + + _timer = new() + { + Interval = new TimeSpan(0, 0, 5), + }; + + _resetCredentials(); + MainViewModel.Database = null; + + try + { + string[] args = Environment.GetCommandLineArgs(); + string databaseFile = Path.GetFullPath(Environment.GetCommandLineArgs()[1]); + if (File.Exists(databaseFile)) + { + _mainViewModel.DatabaseFile = databaseFile; + } + } + catch { } + + _username_TB.KeyUp += _credential_TB_KeyUp; + _password_PB.KeyUp += _credential_TB_KeyUp; + _timer.Tick += _timer_Elapsed; + Loaded += (s, e) => DarkMode.SetDarkMode(this); + } + + private void _timer_Elapsed(object? sender, EventArgs e) + { + _resetCredentials(); + MainViewModel.Database = null; + } + + private void _newUser_MenuItem_Click(object sender, RoutedEventArgs e) + { + UserSettingsView.ShowUserSettings(this); + } + + private void _generatePassword_MenuItem_Click(object sender, RoutedEventArgs e) + { + _ = PasswordGenerator.ShowGeneratePasswordDialog(this); + } + + private void _credential_TB_KeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + _timer.Stop(); + + if (sender == _username_TB) + { + if (string.IsNullOrEmpty(_username_TB.Text)) + { + _timer.Start(); + return; + } + + if (!File.Exists(_mainViewModel.DatabaseFile)) + { + string filename = MainViewModel.CryptographyCenter.GetHash(_username_TB.Text); + _mainViewModel.DatabaseFile = Path.GetFullPath($"{Path.GetDirectoryName(Environment.ProcessPath)}/raw/{filename}.pku"); + } + + try + { + MainViewModel.Database = Database.Open(MainViewModel.CryptographyCenter, + MainViewModel.SerializationCenter, + MainViewModel.PasswordFactory, + MainViewModel.ClipboardManager, + _mainViewModel.DatabaseFile, + _username_TB.Text); + + MainViewModel.Database.DatabaseClosed += _database_DatabaseClosed; + MainViewModel.Database.AutoSaveDetected += _database_AutoSaveDetected; + } + catch { } + + _mainViewModel.CredentialsLabel = "Password :"; + + _username_TB.Text = string.Empty; + _username_TB.Visibility = Visibility.Collapsed; + + _password_PB.Password = string.Empty; + _password_PB.Visibility = Visibility.Visible; + _ = _password_PB.Focus(); + } + else + { + if (string.IsNullOrEmpty(_password_PB.Password)) + { + _timer.Start(); + return; + } + + if (MainViewModel.Database is not null) + { + _ = MainViewModel.Database.Login(_password_PB.Password); + + if (MainViewModel.Database.User is not null) + { + Hide(); + _resetCredentials(); + + if (!UserServicesView.ShowUser(this)) + { + Close(); + } + else + { + _resetCredentials(); + } + } + } + } + + _password_PB.Password = string.Empty; + _timer.Start(); + } + else if (e.Key == Key.Escape) + { + _resetCredentials(); + MainViewModel.Database = null; + } + else + { + _timer.Stop(); + _timer.Start(); + } + } + + private void _database_AutoSaveDetected(object? sender, Interfaces.Events.AutoSaveDetectedEventArgs e) + { + MessageBoxResult result = MessageBox.Show("Unsaved changes have been detected.\nClick Yes to apply these changes.\nClick No to discard them.\nClick Cancel to ignore and keep the save file.", "Autosave detected", MessageBoxButton.YesNoCancel, MessageBoxImage.Question); + + e.MergeBehavior = result switch + { + MessageBoxResult.Cancel => Passkey.Interfaces.Enums.AutoSaveMergeBehavior.MergeWithoutSavingAndKeepAutoSaveFile, + MessageBoxResult.No => Passkey.Interfaces.Enums.AutoSaveMergeBehavior.DontMergeAndRemoveAutoSaveFile, + _ => Passkey.Interfaces.Enums.AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile, + }; + } + + private void _database_DatabaseClosed(object? sender, Interfaces.Events.LogoutEventArgs e) + { + try + { + Dispatcher.Invoke(() => + { + _resetCredentials(); + MainViewModel.Database = null; + Show(); + }); + } + catch { } + } + + private void _resetCredentials() + { + _mainViewModel.DatabaseFile = string.Empty; + _mainViewModel.CredentialsLabel = "Username :"; + + _username_TB.Text = string.Empty; + _username_TB.Visibility = Visibility.Visible; + _ = _username_TB.Focus(); + + _password_PB.Password = string.Empty; + _password_PB.Visibility = Visibility.Collapsed; + + _timer.Stop(); + } + + private void _openDatabase_MenuItem_Click(object sender, RoutedEventArgs e) + { + OpenFileDialog dialog = new() + { + Title = "Open user database file", + Filter = "Passkey user database file|*.pku", + }; + + if (!(dialog.ShowDialog() ?? false)) return; + + _resetCredentials(); + MainViewModel.Database?.Close(); + MainViewModel.Database = null; + _mainViewModel.DatabaseFile = dialog.FileName; + } + } +} \ No newline at end of file diff --git a/GUI/WPF/OSSpecific/ClipboardManager.cs b/GUI/WPF/OSSpecific/ClipboardManager.cs new file mode 100644 index 0000000..3f5efe9 --- /dev/null +++ b/GUI/WPF/OSSpecific/ClipboardManager.cs @@ -0,0 +1,32 @@ +using Upsilon.Apps.Passkey.Interfaces.Utils; +using Windows.ApplicationModel.DataTransfer; + +namespace Upsilon.Apps.Passkey.GUI.WPF.OSSpecific +{ + public class ClipboardManager : IClipboardManager + { + public int RemoveAllOccurence(string[] removeList) + { + int cleanedPasswordCount = 0; + + IReadOnlyList clipboardHistory = Clipboard.GetHistoryItemsAsync().AsTask().GetAwaiter().GetResult().Items; + + foreach (ClipboardHistoryItem? item in clipboardHistory) + { + DataPackageView content = item.Content; + if (content.Contains(StandardDataFormats.Text)) + { + string text = content.GetTextAsync().AsTask().GetAwaiter().GetResult(); + + if (removeList.Any(x => x == text)) + { + _ = Clipboard.DeleteItemFromHistory(item); + cleanedPasswordCount++; + } + } + } + + return cleanedPasswordCount; + } + } +} diff --git a/GUI/WPF/Properties/launchSettings.json b/GUI/WPF/Properties/launchSettings.json new file mode 100644 index 0000000..c667043 --- /dev/null +++ b/GUI/WPF/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "Upsilon.Apps.Passkey.GUI.WPF": { + "commandName": "Project", + "commandLineArgs": "\"vidpll-sVqi93MN9TymqpQ==UGOcrUlBjHsiPMUpOVwOKjiSUBw=.pku\"", + "workingDirectory": "bin\\Debug\\net10.0-windows10.0.18362.0\\raw" + } + } +} \ No newline at end of file diff --git a/GUI/WPF/Themes/DarkMode.cs b/GUI/WPF/Themes/DarkMode.cs new file mode 100644 index 0000000..fd5f8f0 --- /dev/null +++ b/GUI/WPF/Themes/DarkMode.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Themes +{ + public static class DarkMode + { + public static Brush UnchangedBrush1 => new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)); + public static Brush UnchangedBrush2 => new SolidColorBrush(Color.FromRgb(0x2D, 0x2D, 0x30)); + public static Brush ChangedBrush => new SolidColorBrush(Color.FromRgb(0x60, 0x60, 0x60)); + + public static void SetDarkMode(Window window) + { + nint hwnd = new WindowInteropHelper(window).Handle; + + if (hwnd == IntPtr.Zero) + { + return; + } + + int attribute = 20; // DWMWA_USE_IMMERSIVE_DARK_MODE + int useImmersiveDarkMode = 1; + _ = DwmSetWindowAttribute(hwnd, attribute, ref useImmersiveDarkMode, sizeof(int)); + } + + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); + } +} diff --git a/GUI/WPF/Themes/DarkTheme.xaml b/GUI/WPF/Themes/DarkTheme.xaml new file mode 100644 index 0000000..644e73e --- /dev/null +++ b/GUI/WPF/Themes/DarkTheme.xaml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GUI/WPF/Upsilon.Apps.Passkey.GUI.WPF.csproj b/GUI/WPF/Upsilon.Apps.Passkey.GUI.WPF.csproj new file mode 100644 index 0000000..b96cd10 --- /dev/null +++ b/GUI/WPF/Upsilon.Apps.Passkey.GUI.WPF.csproj @@ -0,0 +1,48 @@ + + + + WinExe + net10.0-windows10.0.18362.0 + enable + enable + true + $(AssemblyName) + Yassin Lokhat + A local stored Password Manager GUI. + 3.0.0 + 3.0.0 + 3.0.0 + icon.ico + + + + + + + + + + + + + dll\QRCodeEncoderLibrary.dll + + + + + + Always + + + + + True + 9999 + + + + True + 9999 + + + diff --git a/GUI/WPF/ViewModels/AccountPasswordsWarningViewModel.cs b/GUI/WPF/ViewModels/AccountPasswordsWarningViewModel.cs new file mode 100644 index 0000000..7067cc7 --- /dev/null +++ b/GUI/WPF/ViewModels/AccountPasswordsWarningViewModel.cs @@ -0,0 +1,77 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls; +using Upsilon.Apps.Passkey.Interfaces.Enums; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class AccountPasswordsWarningViewModel : INotifyPropertyChanged + { + public string Title { get; } + + public string ReadableWarningType + { + get => WarningType.ToReadableString(); + set => WarningType = EnumHelper.ActivityWarningTypeFromReadableString(value); + } + public WarningType WarningType + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ReadableWarningType)); + RefreshFilters(); + } + } + } = WarningType.PasswordUpdateReminderWarning | WarningType.PasswordLeakedWarning; + public string Text + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(Text)); + RefreshFilters(); + } + } + } = ""; + + public ObservableCollection Warnings { get; set; } = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public AccountPasswordsWarningViewModel() + { + Title = MainViewModel.AppTitle + " - Account Passwords Warnings"; + RefreshFilters(); + } + + public void RefreshFilters() + { + Warnings.Clear(); + + if (MainViewModel.Database?.Warnings is null) return; + + AccountPasswordWarningViewModel[] warnings = [.. MainViewModel.Database.Warnings + .Where(x => WarningType.HasFlag(x.WarningType)) + .SelectMany(x => x.Accounts?.Select(y => new AccountPasswordWarningViewModel(y, x.WarningType)) ?? []) + .Where(x => x.MeetsConditions(WarningType, Text))]; + + foreach (AccountPasswordWarningViewModel warning in warnings) + { + Warnings.Add(warning); + } + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/AccountPasswordWarningViewModel.cs b/GUI/WPF/ViewModels/Controls/AccountPasswordWarningViewModel.cs new file mode 100644 index 0000000..3395b04 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/AccountPasswordWarningViewModel.cs @@ -0,0 +1,23 @@ +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + internal class AccountPasswordWarningViewModel(IAccount account, WarningType warningType) + { + public string ReadableWarningType => WarningType.ToReadableString(); + public string ServiceString => Account.Service.ToString() ?? string.Empty; + public string AccountString => Account.ToString() ?? string.Empty; + + public IAccount Account = account; + public WarningType WarningType { get; } = warningType; + + public bool MeetsConditions(WarningType warningType, string text) + { + return warningType.HasFlag(WarningType) + && (AccountString.Contains(text, StringComparison.CurrentCultureIgnoreCase) + || ServiceString.Contains(text, StringComparison.CurrentCultureIgnoreCase)); + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/AccountViewModel.cs b/GUI/WPF/ViewModels/Controls/AccountViewModel.cs new file mode 100644 index 0000000..f07a250 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/AccountViewModel.cs @@ -0,0 +1,225 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Media; +using Upsilon.Apps.Passkey.GUI.WPF.Themes; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Models; +using Upsilon.Apps.Passkey.Interfaces.Utils; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + public class AccountViewModel(IAccount account) : INotifyPropertyChanged + { + public readonly IAccount Account = account; + + public string AccountDisplay + { + get + { + string accountDisplay = $"{Account.Label} {Account.Identifiers.First()}"; + return $"{(Account.HasChanged() ? "* " : string.Empty)}{accountDisplay.Trim()}"; + } + } + + public string AccountId => $"Account Id : {Account.ItemId.Replace(Account.Service.ItemId, string.Empty)}"; + + public Brush LabelBackground => Account.HasChanged(nameof(Label)) ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string Label + { + get => Account.Label; + set + { + if (Account.Label != value) + { + Account.Label = value; + OnPropertyChanged(nameof(Label)); + } + } + } + + public ObservableCollection Identifiers = []; + + public Brush PasswordBackground => Account.HasChanged(nameof(Password)) ? DarkMode.ChangedBrush : !PasswordLeaked ? DarkMode.UnchangedBrush2 : Brushes.Red; + public string Password + { + get => Account.Password; + set + { + if (Account.Password != value) + { + Account.Password = value; + OnPropertyChanged(nameof(Password)); + } + } + } + + public PasswordViewModel[] Passwords => [.. Account.Passwords + .OrderByDescending(x => x.Key) + .Select(x => new PasswordViewModel(x.Key.ToShortDateString(), x.Value))]; + + public Brush NotesBackground => Account.HasChanged(nameof(Notes)) ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string Notes + { + get => Account.Notes; + set + { + if (Account.Notes != value) + { + Account.Notes = value; + OnPropertyChanged(nameof(Notes)); + } + } + } + + public int RemindPasswordUpdateDelay + { + get => Account.PasswordUpdateReminderDelay; + set + { + if (Account.PasswordUpdateReminderDelay != value) + { + Account.PasswordUpdateReminderDelay = value; + + OnPropertyChanged(nameof(RemindPasswordUpdateDelay)); + OnPropertyChanged(nameof(RemindPasswordUpdate)); + } + } + } + + public bool RemindPasswordUpdate + { + get => RemindPasswordUpdateDelay != 0; + set + { + if (RemindPasswordUpdate != value) + { + RemindPasswordUpdateDelay = value ? 2 : 0; + OnPropertyChanged(nameof(RemindPasswordUpdate)); + } + } + } + + public bool WarnPasswordLeak + { + get => Account.Options.HasFlag(AccountOption.WarnIfPasswordLeaked); + set + { + if (WarnPasswordLeak != value) + { + if (value) + { + Account.Options |= AccountOption.WarnIfPasswordLeaked; + } + else + { + Account.Options &= ~AccountOption.WarnIfPasswordLeaked; + } + + OnPropertyChanged(nameof(WarnPasswordLeak)); + } + } + } + + public bool WarnIfDuplicatedPassword + { + get => Account.Options.HasFlag(AccountOption.WarnIfDuplicatedPassword); + set + { + if (WarnIfDuplicatedPassword != value) + { + if (value) + { + Account.Options |= AccountOption.WarnIfDuplicatedPassword; + } + else + { + Account.Options &= ~AccountOption.WarnIfDuplicatedPassword; + } + + OnPropertyChanged(nameof(WarnIfDuplicatedPassword)); + } + } + } + + public bool PasswordLeaked + => Account.Options.HasFlag(AccountOption.WarnIfPasswordLeaked) + && MainViewModel.Database?.Warnings is not null + && MainViewModel.Database.Warnings.Any(x => x.WarningType == WarningType.PasswordLeakedWarning + && x.Accounts.Contains(Account)); + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"{propertyName}Background")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AccountDisplay))); + } + + private void _identifierViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != "Identifier") + { + return; + } + + Account.Identifiers = [.. Identifiers.Select(x => x.Identifier)]; + + foreach (IdentifierViewModel? identifier in Identifiers.Except([sender]).Cast()) + { + identifier?.Refresh(); + } + + OnPropertyChanged(string.Empty); + } + + public void AddIdentifier(IdentifierViewModel identifierViewModel) + { + identifierViewModel.PropertyChanged += _identifierViewModel_PropertyChanged; + + _identifierViewModel_PropertyChanged(null, new("Identifier")); + } + + public void AddIdentifier(string identifier) + { + IdentifierViewModel identifierViewModel = new(Account, identifier); + identifierViewModel.PropertyChanged += _identifierViewModel_PropertyChanged; + + Identifiers.Add(identifierViewModel); + + _identifierViewModel_PropertyChanged(null, new("Identifier")); + } + + public bool RemoveIdentifier(IdentifierViewModel identifierViewModel) + { + if (Identifiers.Count == 1) + { + return false; + } + + _ = Identifiers.Remove(identifierViewModel); + + _identifierViewModel_PropertyChanged(null, new("Identifier")); + + return true; + } + + public bool MoveIdentifier(int oldIndex, int newIndex) + { + if (oldIndex < 0 + || newIndex < 0 + || newIndex >= Identifiers.Count) + { + return false; + } + + (Identifiers[newIndex], Identifiers[oldIndex]) = (Identifiers[oldIndex], Identifiers[newIndex]); + + _identifierViewModel_PropertyChanged(null, new("Identifier")); + + return true; + } + + public override string ToString() => $"{(Account.HasChanged() ? "* " : string.Empty)}{Account}"; + } +} diff --git a/GUI/WPF/ViewModels/Controls/ActivityViewModel.cs b/GUI/WPF/ViewModels/Controls/ActivityViewModel.cs new file mode 100644 index 0000000..c81e60a --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/ActivityViewModel.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + internal class ActivityViewModel(IActivity activity) : INotifyPropertyChanged + { + public readonly IActivity Activity = activity; + public string DateTime => Activity.DateTime.ToString("yyyy-MM-dd HH:mm"); + public string EventType => Activity.EventType.ToReadableString(); + public string Message => Activity.Message; + public bool NeedsReview + { + get => Activity.NeedsReview; + set + { + if (Activity.NeedsReview != value) + { + Activity.NeedsReview = value; + OnPropertyChanged(nameof(NeedsReview)); + OnPropertyChanged(nameof(NeedsReviewString)); + } + } + } + public string NeedsReviewString => NeedsReview ? "Needs review" : "Reviewed"; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public bool MeetsConditions(string itemId, DateTime fromDateFilter, DateTime toDateFilter, ActivityEventType eventType, string message, bool needsReview) + { + return (string.IsNullOrEmpty(itemId) || Activity.ItemId == itemId) + && (fromDateFilter > System.DateTime.Now.Date + || Activity.DateTime.Date >= fromDateFilter) && (toDateFilter > System.DateTime.Now.Date + || Activity.DateTime.Date <= toDateFilter) && (eventType == ActivityEventType.None + || Activity.EventType == eventType) && (!needsReview + || Activity.NeedsReview) && Activity.Message.Contains(message, StringComparison.CurrentCultureIgnoreCase); + } + } +} \ No newline at end of file diff --git a/GUI/WPF/ViewModels/Controls/DuplicatedPasswordWarningViewModel.cs b/GUI/WPF/ViewModels/Controls/DuplicatedPasswordWarningViewModel.cs new file mode 100644 index 0000000..8ac61c8 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/DuplicatedPasswordWarningViewModel.cs @@ -0,0 +1,18 @@ +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + internal class DuplicatedPasswordWarningViewModel + { + private readonly IWarning _warning; + + public string DuplicatedPassword => $"{_warning.Accounts?.Length} accounts with same passwords"; + public AccountPasswordWarningViewModel[] Accounts { get; set; } + + public DuplicatedPasswordWarningViewModel(IWarning warning) + { + _warning = warning; + Accounts = [.. _warning.Accounts?.Select(x => new AccountPasswordWarningViewModel(x, _warning.WarningType)) ?? []]; + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/IdentifiantViewModel.cs b/GUI/WPF/ViewModels/Controls/IdentifiantViewModel.cs new file mode 100644 index 0000000..e9281d1 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/IdentifiantViewModel.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using System.Windows.Media; +using Upsilon.Apps.Passkey.GUI.WPF.Themes; +using Upsilon.Apps.Passkey.Interfaces.Models; +using Upsilon.Apps.Passkey.Interfaces.Utils; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + public class IdentifierViewModel : INotifyPropertyChanged + { + private readonly IAccount _account; + + public Brush IdentifierBackground => _account.HasChanged("Identifiers") ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string[] IdentifierAutoCompleteList => _account.Database.User?.Services + .SelectMany(x => x.Accounts) + .SelectMany(x => x.Identifiers) + .Distinct() + .OrderBy(x => x) + .ToArray() ?? []; + + public string Identifier + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(Identifier)); + } + } + } = string.Empty; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"{propertyName}Background")); + } + + public IdentifierViewModel(IAccount account, string identifier) + { + _account = account; + Identifier = identifier; + } + + public void Refresh() + { + OnPropertyChanged(nameof(IdentifierBackground)); + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/PasswordViewModel.cs b/GUI/WPF/ViewModels/Controls/PasswordViewModel.cs new file mode 100644 index 0000000..93f7c9a --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/PasswordViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + public class PasswordViewModel(string updateDate, string password) : INotifyPropertyChanged + { + public string UpdateDate { get; set; } = updateDate; + public string Password { get; set; } = password; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/ServiceViewModel.cs b/GUI/WPF/ViewModels/Controls/ServiceViewModel.cs new file mode 100644 index 0000000..a1070b6 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/ServiceViewModel.cs @@ -0,0 +1,126 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Media; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.Themes; +using Upsilon.Apps.Passkey.Interfaces.Models; +using Upsilon.Apps.Passkey.Interfaces.Utils; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + internal class ServiceViewModel : INotifyPropertyChanged + { + public readonly IService Service; + + public string ServiceDisplay => $"{(Service.HasChanged() ? "* " : string.Empty)}{Service.ServiceName}"; + + public string ServiceId => $"Service Id : {Service.ItemId.Replace(Service.User.ItemId, string.Empty)}"; + + public Brush ServiceNameBackground => Service.HasChanged(nameof(ServiceName)) ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string ServiceName + { + get => Service.ServiceName; + set + { + if (Service.ServiceName != value) + { + Service.ServiceName = value; + OnPropertyChanged(nameof(ServiceName)); + } + } + } + + public Brush UrlBackground => Service.HasChanged(nameof(Url)) ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string Url + { + get => Service.Url; + set + { + if (Service.Url != value) + { + Service.Url = value; + OnPropertyChanged(nameof(Url)); + } + } + } + + public Brush NotesBackground => Service.HasChanged(nameof(Notes)) ? DarkMode.ChangedBrush : DarkMode.UnchangedBrush2; + public string Notes + { + get => Service.Notes; + set + { + if (Service.Notes != value) + { + Service.Notes = value; + OnPropertyChanged(nameof(Notes)); + } + } + } + + public ObservableCollection Accounts = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"{propertyName}Background")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ServiceDisplay))); + } + + public ServiceViewModel(IService service, string identifierFilter = "", string textFilter = "", bool changedItemsOnly = false) + { + Service = service; + + IAccount[] accounts = [.. Service.Accounts.Where(x => x.MeetsFilterConditions(identifierFilter, textFilter, changedItemsOnly))]; + + if (accounts.Length == 0) + { + accounts = Service.Accounts; + } + + foreach (IAccount account in accounts) + { + AccountViewModel accountViewModel = new(account); + accountViewModel.PropertyChanged += _accountViewModel_PropertyChanged; + Accounts.Add(accountViewModel); + } + } + + public AccountViewModel AddAccount() + { + AccountViewModel? accountViewModel = Accounts.FirstOrDefault(x => x.Identifiers.Any(y => y.Identifier == "NewAccount")); + + if (accountViewModel is null) + { + accountViewModel = new(Service.AddAccount(["NewAccount"])); + accountViewModel.PropertyChanged += _accountViewModel_PropertyChanged; + Accounts.Insert(0, accountViewModel); + + OnPropertyChanged(string.Empty); + } + + return accountViewModel; + } + + public int DeleteAccount(AccountViewModel accountViewModel) + { + int index = Accounts.IndexOf(accountViewModel); + + _ = Accounts.Remove(accountViewModel); + Service.DeleteAccount(accountViewModel.Account); + + OnPropertyChanged(string.Empty); + + return index < Accounts.Count ? index : Accounts.Count - 1; + } + + private void _accountViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + OnPropertyChanged(string.Empty); + } + + public override string ToString() => $"{(Service.HasChanged() ? "* " : string.Empty)}{Service}"; + } +} diff --git a/GUI/WPF/ViewModels/Controls/UserPasswordItemViewModel.cs b/GUI/WPF/ViewModels/Controls/UserPasswordItemViewModel.cs new file mode 100644 index 0000000..2f6bb54 --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/UserPasswordItemViewModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + public class UserPasswordItemViewModel : INotifyPropertyChanged + { + public int Index + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = 0; + public string Password + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/GUI/WPF/ViewModels/Controls/VisiblePasswordBoxViewModel.cs b/GUI/WPF/ViewModels/Controls/VisiblePasswordBoxViewModel.cs new file mode 100644 index 0000000..f33800c --- /dev/null +++ b/GUI/WPF/ViewModels/Controls/VisiblePasswordBoxViewModel.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Media; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.Themes; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls +{ + public class VisiblePasswordBoxViewModel : INotifyPropertyChanged + { + public string Password + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + public Visibility PasswordVisibility + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = Visibility.Visible; + public Visibility TextVisibility + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = Visibility.Collapsed; + public Visibility ButtonVisibility + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = Visibility.Visible; + + public bool IsEnabled + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = true; + + public Brush Background + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = DarkMode.UnchangedBrush2; + + public bool PasswordIsVisible => PasswordVisibility == System.Windows.Visibility.Collapsed; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public void TooglePasswordVisibility() + { + if (!PasswordIsVisible) + { + PasswordVisibility = System.Windows.Visibility.Collapsed; + TextVisibility = System.Windows.Visibility.Visible; + } + else + { + PasswordVisibility = System.Windows.Visibility.Visible; + TextVisibility = System.Windows.Visibility.Collapsed; + } + } + } +} diff --git a/GUI/WPF/ViewModels/DuplicatedPasswordsWarningViewModel.cs b/GUI/WPF/ViewModels/DuplicatedPasswordsWarningViewModel.cs new file mode 100644 index 0000000..1d93721 --- /dev/null +++ b/GUI/WPF/ViewModels/DuplicatedPasswordsWarningViewModel.cs @@ -0,0 +1,22 @@ +using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls; +using Upsilon.Apps.Passkey.Interfaces.Enums; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class DuplicatedPasswordsWarningViewModel + { + public string Title { get; } + + public DuplicatedPasswordWarningViewModel[] Warnings { get; set; } + + public DuplicatedPasswordsWarningViewModel() + { + Title = MainViewModel.AppTitle + " - Duplicated Passwords Warnings"; + + Warnings = [.. MainViewModel.Database?.Warnings? + .Where(x => x.WarningType == WarningType.DuplicatedPasswordsWarning) + .Select(x => new DuplicatedPasswordWarningViewModel(x)) + ?? []]; + } + } +} diff --git a/GUI/WPF/ViewModels/MainViewModel.cs b/GUI/WPF/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..dea6495 --- /dev/null +++ b/GUI/WPF/ViewModels/MainViewModel.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using System.IO; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.OSSpecific; +using Upsilon.Apps.Passkey.GUI.WPF.Views; +using Upsilon.Apps.Passkey.Interfaces.Models; +using Upsilon.Apps.Passkey.Interfaces.Utils; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class MainViewModel : INotifyPropertyChanged + { + public static string AppTitle + { + get + { + System.Reflection.AssemblyName package = System.Reflection.Assembly.GetExecutingAssembly().GetName(); + string? packageVersion = package.Version?.ToString(2); + + return $"{package.Name} v{packageVersion}"; + } + } + + public static readonly ICryptographyCenter CryptographyCenter = new CryptographyCenter(); + public static readonly ISerializationCenter SerializationCenter = new JsonSerializationCenter(); + public static readonly IPasswordFactory PasswordFactory = new PasswordFactory(); + public static readonly IClipboardManager ClipboardManager = new ClipboardManager(); + + public static IDatabase? Database = null; + + public static IUser User => Database is null || Database.User is null ? throw new NullReferenceException(nameof(User)) : Database.User; + + public static AccountPasswordsWarningView? AccountPasswordsWarningView; + public static DuplicatedPasswordsWarningView? DuplicatedPasswordsWarningView; + public static UserActivitiesView? UserActivitiesView; + public static Action? GoToItem; + + public string DatabaseFile + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged, nameof(DatabaseLabel)); + } = string.Empty; + + public string DatabaseLabel => File.Exists(DatabaseFile) ? $"Database : {Path.GetFileName(DatabaseFile)}" : "No database loaded."; + + public string CredentialsLabel + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = "Username :"; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/GUI/WPF/ViewModels/PasswordGeneratorViewModel.cs b/GUI/WPF/ViewModels/PasswordGeneratorViewModel.cs new file mode 100644 index 0000000..5448826 --- /dev/null +++ b/GUI/WPF/ViewModels/PasswordGeneratorViewModel.cs @@ -0,0 +1,168 @@ +using System.ComponentModel; +using System.Text; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class PasswordGeneratorViewModel : INotifyPropertyChanged + { + public static string Title => MainViewModel.AppTitle + " - Password Generator"; + + public bool CheckIfLeaked + { + get; + set + { + if (field != value) + { + field = value; + _includeCharactersChanged(nameof(CheckIfLeaked)); + } + } + } = true; + + public int PasswordLength + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(PasswordLength)); + GeneratePassword(); + } + } + } = 20; + + public string GeneratedPassword + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public bool IncludeNumerics + { + get; + set + { + if (field != value) + { + field = value; + _includeCharactersChanged(nameof(IncludeNumerics)); + } + } + } = true; + + public bool IncludeSpecialCharacters + { + get; + set + { + if (field != value) + { + field = value; + _includeCharactersChanged(nameof(IncludeSpecialCharacters)); + } + } + } = true; + + public bool IncludeLowerCaseAlphabet + { + get; + set + { + if (field != value) + { + field = value; + _includeCharactersChanged(nameof(IncludeLowerCaseAlphabet)); + } + } + } = true; + + public bool IncludeUpperCaseAlphabet + { + get; + set + { + if (field != value) + { + field = value; + _includeCharactersChanged(nameof(IncludeUpperCaseAlphabet)); + } + } + } = true; + + private string _alphabet; + public string Alphabet + { + get => _alphabet; + set + { + if (_alphabet != value) + { + _alphabet = value; + OnPropertyChanged(nameof(Alphabet)); + GeneratePassword(); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public PasswordGeneratorViewModel() + { + _alphabet = _buildAlphabet(); + GeneratePassword(); + } + + internal void GeneratePassword() + { + GeneratedPassword = string.Empty; + + _ = Task.Run(() => + { + GeneratedPassword = MainViewModel.PasswordFactory.GeneratePassword(PasswordLength, Alphabet, CheckIfLeaked); + }); + } + + private void _includeCharactersChanged(string propertyName) + { + OnPropertyChanged(propertyName); + + Alphabet = _buildAlphabet(); + } + + private string _buildAlphabet() + { + StringBuilder alphabetBuilder = new(); + + if (IncludeNumerics) + { + _ = alphabetBuilder.Append(MainViewModel.PasswordFactory.Numeric); + } + + if (IncludeUpperCaseAlphabet) + { + _ = alphabetBuilder.Append(MainViewModel.PasswordFactory.Alphabetic.ToUpper()); + } + + if (IncludeLowerCaseAlphabet) + { + _ = alphabetBuilder.Append(MainViewModel.PasswordFactory.Alphabetic.ToLower()); + } + + if (IncludeSpecialCharacters) + { + _ = alphabetBuilder.Append(MainViewModel.PasswordFactory.SpecialChars); + } + + return alphabetBuilder.ToString(); + } + } +} diff --git a/GUI/WPF/ViewModels/UserActivitiesViewModel.cs b/GUI/WPF/ViewModels/UserActivitiesViewModel.cs new file mode 100644 index 0000000..5675bbc --- /dev/null +++ b/GUI/WPF/ViewModels/UserActivitiesViewModel.cs @@ -0,0 +1,131 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls; +using Upsilon.Apps.Passkey.Interfaces.Enums; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class UserActivitiesViewModel : INotifyPropertyChanged + { + public string Title { get; } + + public string FiltersHeader => $"Filters : {Activities.Count} activities found over {MainViewModel.Database?.Activities?.Length}"; + public DateTime FromDateFilter + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(FromDateFilter)); + RefreshFilters(); + } + } + } = DateTime.Now.Date.AddDays(1); + public DateTime ToDateFilter + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ToDateFilter)); + RefreshFilters(); + } + } + } = DateTime.Now.Date.AddDays(1); + + public string ReadableEventType + { + get => EventType.ToReadableString(); + set => EventType = EnumHelper.ActivityEventTypeFromReadableString(value); + } + public ActivityEventType EventType + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ReadableEventType)); + RefreshFilters(); + } + } + } = ActivityEventType.None; + + public string Message + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(Message)); + RefreshFilters(); + } + } + } = ""; + + public bool NeedsReview + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(NeedsReview)); + RefreshFilters(); + } + } + } = false; + + public ObservableCollection Activities { get; set; } = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public UserActivitiesViewModel() + { + Title = MainViewModel.AppTitle + " - Activities"; + + RefreshFilters(); + } + + public void ClearFilters() + { + FromDateFilter = ToDateFilter = DateTime.Now.Date.AddDays(1); + EventType = ActivityEventType.None; + Message = string.Empty; + NeedsReview = false; + } + + public void RefreshFilters(string itemId = "") + { + Activities.Clear(); + + if (MainViewModel.Database?.Activities is null) return; + + ActivityViewModel[] activities = [.. MainViewModel.Database.Activities + .Select(x => new ActivityViewModel(x)) + .Where(x => x.MeetsConditions(itemId, FromDateFilter, ToDateFilter, EventType, Message, NeedsReview)) + .OrderByDescending(x => x.DateTime)]; + + foreach (ActivityViewModel activity in activities) + { + Activities.Add(activity); + } + + OnPropertyChanged(nameof(FiltersHeader)); + } + } +} diff --git a/GUI/WPF/ViewModels/UserServicesViewModel.cs b/GUI/WPF/ViewModels/UserServicesViewModel.cs new file mode 100644 index 0000000..c623682 --- /dev/null +++ b/GUI/WPF/ViewModels/UserServicesViewModel.cs @@ -0,0 +1,200 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Media; +using System.Windows.Threading; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class UserServicesViewModel : INotifyPropertyChanged + { + private readonly string _defaultTitle; + + public string Title + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } + + public string ShowWarnings + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public Brush ShowWarningsColor + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = Brushes.White; + + public string ShowActivityWarnings + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public string ShowExpiredPasswordWarnings + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public string ShowDuplicatedPasswordWarnings + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public string ShowLeakedPasswordWarnings + { + get => field; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = string.Empty; + + public string ServiceFilter + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ServiceFilter)); + + RefreshFilters(); + } + } + } = string.Empty; + + public string IdentifierFilter + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(IdentifierFilter)); + + RefreshFilters(); + } + } + } = string.Empty; + + public string TextFilter + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(TextFilter)); + + RefreshFilters(); + } + } + } = string.Empty; + + public bool ChangedItemsOnly + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ChangedItemsOnly)); + + RefreshFilters(); + } + } + } = false; + + public ObservableCollection Services { get; set; } = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + public event EventHandler? FiltersRefreshed; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public UserServicesViewModel(string defaultTitle) + { + Title = _defaultTitle = defaultTitle; + + RefreshFilters(); + + DispatcherTimer timer = new() + { + Interval = new TimeSpan(0, 0, 0, 0, 500), + IsEnabled = true, + }; + + timer.Tick += _timer_Elapsed; + } + + public ServiceViewModel AddService() + { + ServiceViewModel? serviceViewModel = Services.FirstOrDefault(x => x.ServiceName == "New Service"); + + if (serviceViewModel is null) + { + serviceViewModel = new(MainViewModel.User.AddService("New Service")); + Services.Insert(0, serviceViewModel); + } + + return serviceViewModel; + } + + public int DeleteService(ServiceViewModel serviceViewModel) + { + int index = Services.IndexOf(serviceViewModel); + + _ = Services.Remove(serviceViewModel); + MainViewModel.User.DeleteService(serviceViewModel.Service); + + return index < Services.Count ? index : Services.Count - 1; + } + + public void RefreshFilters() + { + Services.Clear(); + + ServiceViewModel[] services = [.. MainViewModel.User.Services + .Where(x => x.MeetsFilterConditions(ServiceFilter, IdentifierFilter, TextFilter, ChangedItemsOnly)) + .OrderBy(x => x.ServiceName) + .Select(x => new ServiceViewModel(x, IdentifierFilter, TextFilter, ChangedItemsOnly))]; + + foreach (ServiceViewModel service in services) + { + Services.Add(service); + } + + FiltersRefreshed?.Invoke(this, EventArgs.Empty); + } + + private void _timer_Elapsed(object? sender, EventArgs e) + { + string title = _defaultTitle; + + if (MainViewModel.Database?.User is not null) + { + if (MainViewModel.Database.User.HasChanged()) + { + title += " - *"; + } + + int sessionLeftTime = MainViewModel.Database.SessionLeftTime ?? 0; + title += $" - Left session time : {sessionLeftTime / 60:D2}:{sessionLeftTime % 60:D2}"; + } + + Title = title; + } + } +} diff --git a/GUI/WPF/ViewModels/UserSettingsViewModel.cs b/GUI/WPF/ViewModels/UserSettingsViewModel.cs new file mode 100644 index 0000000..521addf --- /dev/null +++ b/GUI/WPF/ViewModels/UserSettingsViewModel.cs @@ -0,0 +1,196 @@ +using System.ComponentModel; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; + +namespace Upsilon.Apps.Passkey.GUI.WPF.ViewModels +{ + internal class UserSettingsViewModel : INotifyPropertyChanged + { + public string Title { get; } + public string Username + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = "NewUser"; + public int LogoutTimeout + { + get; + set + { + if (field != value) + { + field = value; + + OnPropertyChanged(nameof(LogoutTimeout)); + OnPropertyChanged(nameof(LogoutTimeoutChecked)); + } + } + } = 5; + public bool LogoutTimeoutChecked + { + get => LogoutTimeout != 0; + set + { + if (LogoutTimeoutChecked != value) + { + LogoutTimeout = value ? 5 : 0; + OnPropertyChanged(nameof(LogoutTimeoutChecked)); + } + } + } + public int CleaningClipboardTimeout + { + get; + set + { + if (field != value) + { + field = value; + + OnPropertyChanged(nameof(CleaningClipboardTimeout)); + OnPropertyChanged(nameof(CleaningClipboardTimeoutChecked)); + } + } + } = 30; + public bool CleaningClipboardTimeoutChecked + { + get => CleaningClipboardTimeout != 0; + set + { + if (CleaningClipboardTimeoutChecked != value) + { + CleaningClipboardTimeout = value ? 30 : 0; + OnPropertyChanged(nameof(CleaningClipboardTimeoutChecked)); + } + } + } + public int ShowPasswordDelay + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(ShowPasswordDelay)); + OnPropertyChanged(nameof(ShowPasswordDelayChecked)); + } + } + } = 500; + public bool ShowPasswordDelayChecked + { + get => ShowPasswordDelay != 0; + set + { + if (ShowPasswordDelayChecked != value) + { + ShowPasswordDelay = value ? 500 : 0; + OnPropertyChanged(nameof(ShowPasswordDelayChecked)); + } + } + } + public int NumberOfOldPasswordToKeep + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(NumberOfOldPasswordToKeep)); + OnPropertyChanged(nameof(NumberOfOldPasswordToKeepChecked)); + } + } + } = 0; + public bool NumberOfOldPasswordToKeepChecked + { + get => NumberOfOldPasswordToKeep != 0; + set + { + if (NumberOfOldPasswordToKeepChecked != value) + { + NumberOfOldPasswordToKeep = value ? 10 : 0; + OnPropertyChanged(nameof(NumberOfOldPasswordToKeepChecked)); + } + } + } + public int NumberOfMonthActivitiesToKeep + { + get; + set + { + if (field != value) + { + field = value; + OnPropertyChanged(nameof(NumberOfMonthActivitiesToKeep)); + OnPropertyChanged(nameof(NumberOfMonthActivitiesToKeepChecked)); + } + } + } = 0; + public bool NumberOfMonthActivitiesToKeepChecked + { + get => NumberOfMonthActivitiesToKeep != 0; + set + { + if (NumberOfMonthActivitiesToKeepChecked != value) + { + NumberOfMonthActivitiesToKeep = value ? 12 : 0; + OnPropertyChanged(nameof(NumberOfMonthActivitiesToKeepChecked)); + } + } + } + public bool NotifyActivityReview + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = true; + public bool NotifyPasswordUpdateReminder + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = true; + public bool NotifyDuplicatedPasswords + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = true; + public bool NotifyPasswordLeaked + { + get; + set => PropertyHelper.SetProperty(ref field, value, this, PropertyChanged); + } = true; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public UserSettingsViewModel() + { + Title = MainViewModel.AppTitle; + + if (MainViewModel.Database is null || MainViewModel.Database.User is null) + { + Title += " - New user"; + } + else + { + Title += " - User settings"; + + Username = MainViewModel.Database.User.Username; + + LogoutTimeout = MainViewModel.Database.User.LogoutTimeout; + CleaningClipboardTimeout = MainViewModel.Database.User.CleaningClipboardTimeout; + ShowPasswordDelay = MainViewModel.Database.User.ShowPasswordDelay; + NumberOfOldPasswordToKeep = MainViewModel.Database.User.NumberOfOldPasswordToKeep; + NumberOfMonthActivitiesToKeep = MainViewModel.Database.User.NumberOfMonthActivitiesToKeep; + + NotifyActivityReview = (MainViewModel.Database.User.WarningsToNotify & Passkey.Interfaces.Enums.WarningType.ActivityReviewWarning) != 0; + NotifyPasswordUpdateReminder = (MainViewModel.Database.User.WarningsToNotify & Passkey.Interfaces.Enums.WarningType.PasswordUpdateReminderWarning) != 0; + NotifyDuplicatedPasswords = (MainViewModel.Database.User.WarningsToNotify & Passkey.Interfaces.Enums.WarningType.DuplicatedPasswordsWarning) != 0; + NotifyPasswordLeaked = (MainViewModel.Database.User.WarningsToNotify & Passkey.Interfaces.Enums.WarningType.PasswordLeakedWarning) != 0; + } + } + } +} diff --git a/GUI/WPF/Views/AccountPasswordsWarningView.xaml b/GUI/WPF/Views/AccountPasswordsWarningView.xaml new file mode 100644 index 0000000..8d19fc8 --- /dev/null +++ b/GUI/WPF/Views/AccountPasswordsWarningView.xaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + +