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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GUI/WPF/Views/AccountPasswordsWarningView.xaml.cs b/GUI/WPF/Views/AccountPasswordsWarningView.xaml.cs
new file mode 100644
index 0000000..29371cc
--- /dev/null
+++ b/GUI/WPF/Views/AccountPasswordsWarningView.xaml.cs
@@ -0,0 +1,45 @@
+using System.Windows;
+using Upsilon.Apps.Passkey.GUI.WPF.Helper;
+using Upsilon.Apps.Passkey.GUI.WPF.Themes;
+using Upsilon.Apps.Passkey.GUI.WPF.ViewModels;
+using Upsilon.Apps.Passkey.Interfaces.Enums;
+
+namespace Upsilon.Apps.Passkey.GUI.WPF.Views
+{
+ ///
+ /// Interaction logic for ExpiredOrLeakedPasswordsWarningView.xaml
+ ///
+ public partial class AccountPasswordsWarningView : Window
+ {
+ private readonly AccountPasswordsWarningViewModel _viewModel;
+
+ internal AccountPasswordsWarningView(WarningType warningType)
+ {
+ InitializeComponent();
+
+ DataContext = _viewModel = new()
+ {
+ WarningType = warningType,
+ };
+
+ _ = _warningType_CB.Items.Add((WarningType.PasswordUpdateReminderWarning | WarningType.PasswordLeakedWarning).ToReadableString());
+ _ = _warningType_CB.Items.Add(WarningType.PasswordLeakedWarning.ToReadableString());
+ _ = _warningType_CB.Items.Add(WarningType.PasswordUpdateReminderWarning.ToReadableString());
+
+ _warnings_DGV.ItemsSource = _viewModel.Warnings;
+
+ Loaded += (s, e) => DarkMode.SetDarkMode(this);
+ }
+
+ private void _filterClear_Button_Click(object sender, RoutedEventArgs e)
+ {
+ _viewModel.WarningType = WarningType.PasswordUpdateReminderWarning | WarningType.PasswordLeakedWarning;
+ _viewModel.Text = string.Empty;
+ }
+
+ private void _viewItemButton_Click(object sender, RoutedEventArgs e)
+ {
+ MainViewModel.GoToItem?.Invoke(_viewModel.Warnings[_warnings_DGV.SelectedIndex].Account.ItemId);
+ }
+ }
+}
diff --git a/GUI/WPF/Views/Controls/AccountView.xaml b/GUI/WPF/Views/Controls/AccountView.xaml
new file mode 100644
index 0000000..f14b72e
--- /dev/null
+++ b/GUI/WPF/Views/Controls/AccountView.xaml
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warn if the password leaked.
+ Warn if the password is same as another account's password.
+
+
+
+
+
+
+
diff --git a/GUI/WPF/Views/Controls/AccountView.xaml.cs b/GUI/WPF/Views/Controls/AccountView.xaml.cs
new file mode 100644
index 0000000..2eacf4e
--- /dev/null
+++ b/GUI/WPF/Views/Controls/AccountView.xaml.cs
@@ -0,0 +1,259 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using Upsilon.Apps.Passkey.GUI.WPF.Helper;
+using Upsilon.Apps.Passkey.GUI.WPF.ViewModels;
+using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls;
+
+namespace Upsilon.Apps.Passkey.GUI.WPF.Views.Controls
+{
+ ///
+ /// Interaction logic for AccountView.xaml
+ ///
+ public partial class AccountView : UserControl
+ {
+ private AccountViewModel? _viewModel;
+
+ public AccountView()
+ {
+ InitializeComponent();
+ }
+
+ public string? GetIdentifier()
+ {
+ return _identifiers_LB.SelectedItem is not IdentifierViewModel identifierViewModel ? null : identifierViewModel.Identifier;
+ }
+
+ public string? GetPassword() => _viewModel?.Password;
+
+ public void SetPassword(string password)
+ {
+ if (_viewModel is null) return;
+
+ _viewModel.Password = password;
+
+ _password_VPB.Password = _viewModel.Password;
+ _password_VPB.BackgroundColor = _viewModel.PasswordBackground;
+ _passwords_LB.ItemsSource = _viewModel.Passwords;
+ }
+
+ public void SetDataContext(AccountViewModel? dataContext)
+ {
+ if (dataContext is null)
+ {
+ DataContext = null;
+ _viewModel = null;
+ _identifiers_LB.ItemsSource = null;
+
+ return;
+ }
+
+ DataContext = _viewModel = dataContext;
+
+ _viewModel.Identifiers.Clear();
+
+ if (_viewModel.Account.Identifiers.Length == 0)
+ {
+ _viewModel.AddIdentifier(string.Empty);
+ }
+ else
+ {
+ _viewModel.Identifiers = [.. _viewModel.Account.Identifiers.Select(x => new IdentifierViewModel(_viewModel.Account, x))];
+ foreach (IdentifierViewModel identifier in _viewModel.Identifiers)
+ {
+ _viewModel.AddIdentifier(identifier);
+ }
+ }
+
+ _identifiers_LB.ItemsSource = _viewModel.Identifiers;
+ _identifiers_LB.SelectedIndex = 0;
+
+ _password_VPB.Password = _viewModel.Password;
+ _password_VPB.BackgroundColor = _viewModel.PasswordBackground;
+ _passwords_LB.ItemsSource = _viewModel.Passwords;
+ }
+
+ private void _identifier_DeleteClicked(object? sender, EventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ int index = _identifiers_LB.SelectedIndex;
+
+ if (_viewModel.RemoveIdentifier((IdentifierViewModel)_identifiers_LB.SelectedItem))
+ {
+ _identifiers_LB.SelectedIndex = index < _viewModel.Identifiers.Count ? index : _viewModel.Identifiers.Count - 1;
+ }
+ }
+
+ private void _identifier_UpClicked(object? sender, EventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ int newIndex = _identifiers_LB.SelectedIndex - 1;
+
+ if (_viewModel.MoveIdentifier(_identifiers_LB.SelectedIndex, newIndex))
+ {
+ _identifiers_LB.SelectedIndex = newIndex;
+ }
+ }
+
+ private void _identifier_DownClicked(object? sender, EventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ int newIndex = _identifiers_LB.SelectedIndex + 1;
+
+ if (_viewModel.MoveIdentifier(_identifiers_LB.SelectedIndex, newIndex))
+ {
+ _identifiers_LB.SelectedIndex = newIndex;
+ }
+ }
+
+ private void _addButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()) return;
+
+ _viewModel?.AddIdentifier(string.Empty);
+ _identifiers_LB.SelectedIndex = _identifiers_LB.Items.Count - 1;
+ }
+
+ private void _value_TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
+ {
+ NumericTextBoxHelper.PreviewTextInput(sender, e);
+ }
+
+ private void _value_TextBox_Pasting(object sender, DataObjectPastingEventArgs e)
+ {
+ NumericTextBoxHelper.Pasting(sender, e);
+ }
+
+ private void _value_TextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ NumericTextBoxHelper.TextChanged(sender, e);
+ }
+
+ private void _password_VPB_Validated(object sender, EventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ _viewModel.Password = _password_VPB.Password;
+ _password_VPB.BackgroundColor = _viewModel.PasswordBackground;
+ _passwords_LB.ItemsSource = _viewModel.Passwords;
+ }
+
+ private void _passwords_VPB_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not VisiblePasswordBox passwordBox) return;
+
+ try
+ {
+ passwordBox.Password = ((PasswordViewModel)((ContentPresenter)passwordBox.TemplatedParent).Content).Password;
+ }
+ catch { }
+ }
+
+ private void _copyIdentifier_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()) return;
+
+ QrCodeView.CopyToClipboard(((IdentifierViewModel)_identifiers_LB.SelectedItem).Identifier);
+ }
+
+ private void _showQrCodeIdentifier_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()) return;
+
+ QrCodeView.ShowQrCode(Window.GetWindow(this),
+ ((IdentifierViewModel)_identifiers_LB.SelectedItem).Identifier,
+ MainViewModel.User.ShowPasswordDelay);
+ }
+
+ private void _copyPassword_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ QrCodeView.CopyToClipboard(_viewModel.Password);
+ }
+
+ private void _showQrCodePassword_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ QrCodeView.ShowQrCode(Window.GetWindow(this),
+ _viewModel.Password,
+ MainViewModel.User.ShowPasswordDelay);
+ }
+
+ private void _copyPasswords_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()
+ || sender is not Button button)
+ {
+ return;
+ }
+
+ QrCodeView.CopyToClipboard(((PasswordViewModel)((ContentPresenter)button.TemplatedParent).Content).Password);
+ }
+
+ private void _showQrCodePasswords_Clicked(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()
+ || sender is not Button button)
+ {
+ return;
+ }
+
+ QrCodeView.ShowQrCode(Window.GetWindow(this),
+ ((PasswordViewModel)((ContentPresenter)button.TemplatedParent).Content).Password,
+ MainViewModel.User.ShowPasswordDelay);
+ }
+
+ private void _viewActivities_Button_Click(object sender, RoutedEventArgs e)
+ {
+ if (this.GetIsBusy()
+ || _viewModel is null)
+ {
+ return;
+ }
+
+ if (this.GetIsBusy()) return;
+
+ if (MainViewModel.UserActivitiesView is not null
+ && MainViewModel.UserActivitiesView.IsLoaded)
+ {
+ MainViewModel.UserActivitiesView.ViewModel.RefreshFilters(_viewModel.Account.ItemId);
+ _ = MainViewModel.UserActivitiesView.Activate();
+ return;
+ }
+
+ MainViewModel.UserActivitiesView = new(needsReviewFilter: false);
+ MainViewModel.UserActivitiesView.ViewModel.ClearFilters();
+ MainViewModel.UserActivitiesView.ViewModel.RefreshFilters(_viewModel.Account.ItemId);
+ MainViewModel.UserActivitiesView.Show();
+ }
+ }
+}
diff --git a/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml b/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml
new file mode 100644
index 0000000..bb5bed0
--- /dev/null
+++ b/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml.cs b/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml.cs
new file mode 100644
index 0000000..924e24b
--- /dev/null
+++ b/GUI/WPF/Views/Controls/AutoCompleteTextBox.xaml.cs
@@ -0,0 +1,116 @@
+using System.Collections;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using Upsilon.Apps.Passkey.GUI.WPF.ViewModels.Controls;
+
+namespace Upsilon.Apps.Passkey.GUI.WPF.Views.Controls
+{
+ ///
+ /// Interaction logic for AutoCompleteTextBox.xaml
+ ///
+ public partial class AutoCompleteTextBox : TextBox
+ {
+ public static readonly DependencyProperty ItemsSourceProperty =
+ DependencyProperty.RegisterAttached("ItemsSource", typeof(IEnumerable), typeof(AutoCompleteTextBox),
+ new PropertyMetadata(null, _onItemsSourceChanged));
+
+ public AutoCompleteTextBox()
+ {
+ InitializeComponent();
+ }
+
+ public static IEnumerable GetItemsSource(DependencyObject obj) => (IEnumerable)obj.GetValue(ItemsSourceProperty);
+ public static void SetItemsSource(DependencyObject obj, IEnumerable value) => obj.SetValue(ItemsSourceProperty, value);
+
+ private static void _onItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is TextBox textBox)
+ {
+ textBox.TextChanged -= _textBox_TextChanged;
+
+ if (e.NewValue is not null)
+ {
+ textBox.TextChanged += _textBox_TextChanged;
+ }
+ _attachPopup(textBox);
+ }
+ }
+
+ private static void _textBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ TextBox textBox = (TextBox)sender;
+ Popup popup = _getPopup(textBox);
+ if (popup is null) return;
+
+ popup.IsOpen = false;
+
+ IEnumerable