< Summary

Information
Class: LOCKnet.App.ViewModels.CredentialDetailViewModel
Assembly: LOCKnet.App
File(s): /home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.App/ViewModels/CredentialDetailViewModel.cs
Line coverage
67%
Covered lines: 184
Uncovered lines: 87
Coverable lines: 271
Total lines: 482
Line coverage: 67.8%
Branch coverage
53%
Covered branches: 74
Total branches: 138
Branch coverage: 53.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.App/ViewModels/CredentialDetailViewModel.cs

#LineLine coverage
 1using Avalonia.Controls;
 2using CommunityToolkit.Mvvm.ComponentModel;
 3using CommunityToolkit.Mvvm.Input;
 4using LOCKnet.Core.DataAbstractions;
 5using LOCKnet.Core.Services;
 6using System.Collections.ObjectModel;
 7using System.Security;
 8using System.Text.Json;
 9
 10namespace LOCKnet.App.ViewModels;
 11
 12/// <summary>
 13/// ViewModel für das Hinzufügen / Bearbeiten eines Credentials.
 14/// </summary>
 15public partial class CredentialDetailViewModel : ViewModelBase
 16{
 17  public event EventHandler? SaveCompleted;
 18  public event EventHandler? Cancelled;
 19
 20  private readonly int? _editId;
 521  private readonly IPasswordGeneratorService _generator = new PasswordGeneratorService();
 522  private readonly IPasswordStrengthService _strengthService = new PasswordStrengthService();
 23
 24  [ObservableProperty]
 25  [NotifyPropertyChangedFor(nameof(IsPasswordCredential))]
 26  [NotifyPropertyChangedFor(nameof(IsApiKeyCredential))]
 27  [NotifyPropertyChangedFor(nameof(IsBackupCodesCredential))]
 28  [NotifyPropertyChangedFor(nameof(SecretFieldLabel))]
 29  [NotifyPropertyChangedFor(nameof(SecretFieldWatermark))]
 30  [NotifyPropertyChangedFor(nameof(UsernameLabel))]
 531  private CredentialType _credentialType = CredentialType.Password;
 32
 33  /// <summary>Gibt an ob der aktuelle Typ 'Passwort' ist. Steuert RadioButton-Binding.</summary>
 34  public bool IsPasswordCredential
 35  {
 336    get => CredentialType == CredentialType.Password;
 437    set { if (value) CredentialType = CredentialType.Password; }
 38  }
 39
 40  /// <summary>Gibt an ob der aktuelle Typ 'API-Schluessel' ist. Steuert RadioButton-Binding.</summary>
 41  public bool IsApiKeyCredential
 42  {
 443    get => CredentialType == CredentialType.ApiKey;
 444    set { if (value) CredentialType = CredentialType.ApiKey; }
 45  }
 46
 47  /// <summary>Gibt an ob der aktuelle Typ 'Backup-Codes' ist.</summary>
 48  public bool IsBackupCodesCredential
 49  {
 050    get => CredentialType == CredentialType.BackupCodes;
 051    set { if (value) CredentialType = CredentialType.BackupCodes; }
 52  }
 53
 54  /// <summary>Dynamisches Label fuer das Geheimnis-Feld je nach Credential-Typ.</summary>
 255  public string SecretFieldLabel => CredentialType == CredentialType.ApiKey ? "API-Schlüssel *" : "Passwort *";
 56
 57  /// <summary>Dynamischer Watermark-Text fuer das Geheimnis-Feld je nach Credential-Typ.</summary>
 258  public string SecretFieldWatermark => CredentialType == CredentialType.ApiKey
 259    ? (IsEditMode ? "Leer lassen, um aktuellen API-Schlüssel zu behalten" : "API-Schlüssel eingeben")
 260    : (IsEditMode ? "Leer lassen, um aktuelles Passwort zu behalten" : "Passwort eingeben");
 61
 62  /// <summary>Dynamisches Label fuer das Benutzername-Feld je nach Credential-Typ.</summary>
 163  public string UsernameLabel => CredentialType switch
 164  {
 165    CredentialType.ApiKey => "Client-ID / Bezeichner",
 066    CredentialType.BackupCodes => "Account / E-Mail",
 067    _ => "Benutzername"
 168  };
 69  [ObservableProperty]
 70  [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
 571  private string _title = string.Empty;
 72
 73  [ObservableProperty]
 574  private string _username = string.Empty;
 75
 76  [ObservableProperty]
 77  [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
 578  private string _password = string.Empty;
 79
 80  [ObservableProperty]
 581  private string _url = string.Empty;
 82
 83  [ObservableProperty]
 584  private string _notes = string.Empty;
 85
 86  [ObservableProperty]
 587  private string _errorMessage = string.Empty;
 88
 89  [ObservableProperty]
 90  private bool _isPasswordVisible;
 91
 92  [ObservableProperty]
 593  private string _iconKey = string.Empty;
 94
 95  [ObservableProperty]
 596  private bool _useUppercase = true;
 97
 98  [ObservableProperty]
 599  private bool _useLowercase = true;
 100
 101  [ObservableProperty]
 5102  private bool _useDigits = true;
 103
 104  [ObservableProperty]
 5105  private bool _useSpecial = true;
 106
 107  [ObservableProperty]
 5108  private int _passwordLength = 16;
 109
 110  [ObservableProperty]
 111  private int _strengthScore;
 112
 113  [ObservableProperty]
 5114  private string _strengthLabel = string.Empty;
 115
 116  [ObservableProperty]
 5117  private string _strengthColor = "#50506C";
 118
 119  [ObservableProperty]
 5120  private string _strengthSeg1 = "#2E3460";
 121
 122  [ObservableProperty]
 5123  private string _strengthSeg2 = "#2E3460";
 124
 125  [ObservableProperty]
 5126  private string _strengthSeg3 = "#2E3460";
 127
 128  [ObservableProperty]
 5129  private string _strengthSeg4 = "#2E3460";
 130
 131  [ObservableProperty]
 5132  private string _strengthSeg5 = "#2E3460";
 133
 134  [ObservableProperty]
 135  private bool _showGenerator;
 136
 137  [ObservableProperty]
 5138  private string _backupCodesInput = string.Empty;
 139
 140  [ObservableProperty]
 5141  private bool _showUsedBackupCodes = true;
 142
 12143  public ObservableCollection<BackupCodeItemModel> BackupCodes { get; } = [];
 144
 13145  public bool IsEditMode => _editId.HasValue;
 2146  public string WindowTitle => IsEditMode ? "Credential bearbeiten" : "Neues Credential";
 147
 148  // ── Konstruktor (neu) ─────────────────────────────────────────────────────
 3149  public CredentialDetailViewModel()
 3150  {
 3151    UpdateStrengthDetails(Password);
 3152  }
 153
 154  // ── Konstruktor (bearbeiten) ───────────────────────────────────────────────
 2155  public CredentialDetailViewModel(CredentialRecord record)
 2156  {
 2157    _editId = record.Id;
 2158    Title = record.Title;
 2159    Username = record.Username ?? string.Empty;
 2160    Url = record.Url ?? string.Empty;
 2161    Notes = record.Notes ?? string.Empty;
 2162    IconKey = record.IconKey ?? string.Empty;
 2163    CredentialType = record.CredentialType;
 2164    if (CredentialType == CredentialType.BackupCodes)
 0165      LoadBackupCodes(record.Id);
 166
 2167    UpdateStrengthDetails(Password);
 2168  }
 169
 170  // ── Commands ──────────────────────────────────────────────────────────────
 171
 172  [RelayCommand(CanExecute = nameof(CanSave))]
 173  private void Save()
 6174  {
 6175    ErrorMessage = string.Empty;
 176    try
 6177    {
 6178      if (CredentialType == CredentialType.BackupCodes)
 1179      {
 1180        if (BackupCodes.Count == 0)
 0181        {
 0182          ErrorMessage = "Mindestens ein Backup-Code ist erforderlich.";
 0183          return;
 184        }
 185
 1186        Password = JsonSerializer.Serialize(new BackupCodesPayload
 1187        {
 2188          Items = BackupCodes.Select((item, index) => new BackupCodeEntry
 2189          {
 2190            Value = item.Value,
 2191            IsUsed = item.IsUsed,
 2192            UsedAt = item.IsUsed ? item.UsedAt : null,
 2193            SortOrder = index,
 2194          }).ToList()
 1195        });
 1196      }
 197
 6198      var secure = string.IsNullOrEmpty(Password) ? null : ToSecureString(Password);
 199
 6200      if (IsEditMode)
 2201      {
 2202        AppServices.Current.CredentialService.Update(
 2203          _editId!.Value, Title,
 2204          string.IsNullOrWhiteSpace(Username) ? null : Username,
 2205          secure,
 2206          string.IsNullOrWhiteSpace(Url) ? null : Url,
 2207          string.IsNullOrWhiteSpace(Notes) ? null : Notes,
 2208          string.IsNullOrWhiteSpace(IconKey) ? null : IconKey,
 2209          CredentialType);
 1210      }
 211      else
 4212      {
 4213        if (secure is null)
 1214        {
 1215          ErrorMessage = CredentialType == CredentialType.ApiKey ? "API-Schlüssel ist erforderlich." : "Passwort ist erf
 1216          return;
 217        }
 218
 3219        AppServices.Current.CredentialService.Add(
 3220          Title,
 3221          string.IsNullOrWhiteSpace(Username) ? null : Username,
 3222          secure,
 3223          string.IsNullOrWhiteSpace(Url) ? null : Url,
 3224          string.IsNullOrWhiteSpace(Notes) ? null : Notes,
 3225          string.IsNullOrWhiteSpace(IconKey) ? null : IconKey,
 3226          CredentialType);
 3227      }
 228
 4229      SaveCompleted?.Invoke(this, EventArgs.Empty);
 4230    }
 1231    catch (Exception ex)
 1232    {
 1233      ErrorMessage = ex.Message;
 1234    }
 6235  }
 236
 237  private bool CanSave()
 1238  {
 1239    if (string.IsNullOrWhiteSpace(Title))
 0240      return false;
 241
 1242    if (CredentialType == CredentialType.BackupCodes)
 0243      return BackupCodes.Count > 0;
 244
 1245    return IsEditMode || Password.Length > 0;
 1246  }
 247
 248  [RelayCommand]
 249  private void ImportBackupCodes()
 1250  {
 7251    foreach (var code in BackupCodeParser.Parse(BackupCodesInput))
 2252    {
 3253      if (BackupCodes.Any(x => string.Equals(x.Value, code, StringComparison.OrdinalIgnoreCase)))
 0254        continue;
 255
 2256      BackupCodes.Add(new BackupCodeItemModel
 2257      {
 2258        Value = code,
 2259        IsUsed = false,
 2260      });
 2261    }
 262
 1263    BackupCodesInput = string.Empty;
 1264    SaveCommand.NotifyCanExecuteChanged();
 1265  }
 266
 267  [RelayCommand]
 268  private void ToggleBackupCodeUsed(BackupCodeItemModel? item)
 0269  {
 0270    if (item is null)
 0271      return;
 272
 0273    item.IsUsed = !item.IsUsed;
 0274    item.UsedAt = item.IsUsed ? DateTime.UtcNow : null;
 0275  }
 276
 277  [RelayCommand]
 278  private void RemoveBackupCode(BackupCodeItemModel? item)
 0279  {
 0280    if (item is null)
 0281      return;
 282
 0283    BackupCodes.Remove(item);
 0284    SaveCommand.NotifyCanExecuteChanged();
 0285  }
 286
 287  [RelayCommand]
 288  private void CopyBackupCode(BackupCodeItemModel? item)
 0289  {
 0290    if (item is null)
 0291      return;
 292
 0293    _ = CopyToClipboardAsync(item.Value);
 0294  }
 295
 296  [RelayCommand]
 297  private void CopyActiveBackupCodes()
 0298  {
 0299    var text = string.Join(Environment.NewLine, BackupCodes.Where(c => !c.IsUsed).Select(c => c.Value));
 0300    if (text.Length > 0)
 0301      _ = CopyToClipboardAsync(text);
 0302  }
 303
 304  [RelayCommand]
 305  private void CopyAllBackupCodes()
 0306  {
 0307    var text = string.Join(Environment.NewLine, BackupCodes.Select(c => c.Value));
 0308    if (text.Length > 0)
 0309      _ = CopyToClipboardAsync(text);
 0310  }
 311
 312  [RelayCommand]
 1313  private void Cancel() => Cancelled?.Invoke(this, EventArgs.Empty);
 314
 315  [RelayCommand]
 2316  private void TogglePasswordVisibility() => IsPasswordVisible = !IsPasswordVisible;
 317
 318  [RelayCommand]
 1319  private void ToggleGenerator() => ShowGenerator = !ShowGenerator;
 320
 321  [RelayCommand]
 322  private void GeneratePassword()
 1323  {
 1324    var generated = _generator.Generate(new PasswordGeneratorOptions
 1325    {
 1326      Length = PasswordLength,
 1327      UseUppercase = UseUppercase,
 1328      UseLowercase = UseLowercase,
 1329      UseDigits = UseDigits,
 1330      UseSpecial = UseSpecial
 1331    });
 332
 1333    Password = generated;
 1334    ShowGenerator = false;
 1335  }
 336
 337  [RelayCommand]
 1338  private void SetIcon(string key) => IconKey = key;
 339
 340  partial void OnCredentialTypeChanged(CredentialType value)
 4341  {
 4342    if (value != CredentialType.BackupCodes)
 3343      BackupCodesInput = string.Empty;
 344
 4345    SaveCommand.NotifyCanExecuteChanged();
 4346  }
 347
 5348  partial void OnPasswordChanged(string value) => UpdateStrengthDetails(value);
 349
 350  partial void OnStrengthScoreChanged(int value)
 5351  {
 5352    var litSegments = GetLitStrengthSegments();
 5353    StrengthSeg1 = litSegments >= 1 ? StrengthColor : "#2E3460";
 5354    StrengthSeg2 = litSegments >= 2 ? StrengthColor : "#2E3460";
 5355    StrengthSeg3 = litSegments >= 3 ? StrengthColor : "#2E3460";
 5356    StrengthSeg4 = litSegments >= 4 ? StrengthColor : "#2E3460";
 5357    StrengthSeg5 = litSegments >= 5 ? StrengthColor : "#2E3460";
 5358  }
 359
 360  partial void OnStrengthColorChanged(string value)
 10361  {
 10362    var litSegments = GetLitStrengthSegments();
 10363    StrengthSeg1 = litSegments >= 1 ? value : "#2E3460";
 10364    StrengthSeg2 = litSegments >= 2 ? value : "#2E3460";
 10365    StrengthSeg3 = litSegments >= 3 ? value : "#2E3460";
 10366    StrengthSeg4 = litSegments >= 4 ? value : "#2E3460";
 10367    StrengthSeg5 = litSegments >= 5 ? value : "#2E3460";
 10368  }
 369
 370  // ── Helpers ───────────────────────────────────────────────────────────────
 371
 372  private static SecureString ToSecureString(string s)
 4373  {
 4374    var secure = new SecureString();
 507375    foreach (var c in s) secure.AppendChar(c);
 4376    secure.MakeReadOnly();
 4377    return secure;
 4378  }
 379
 380  private void UpdateStrengthDetails(string password)
 10381  {
 10382    var strength = _strengthService.Evaluate(password);
 10383    StrengthScore = strength.Score;
 10384    StrengthLabel = strength.Label;
 10385    StrengthColor = strength.Color;
 10386  }
 387
 388  private int GetLitStrengthSegments()
 15389  {
 15390    if (string.IsNullOrEmpty(Password))
 5391    {
 5392      return 0;
 393    }
 394
 10395    return Math.Clamp(StrengthScore + 1, 1, 5);
 15396  }
 397
 398  private void LoadBackupCodes(int id)
 0399  {
 0400    var secure = AppServices.Current.CredentialService.GetPassword(id);
 0401    if (secure is null)
 0402      return;
 403
 0404    var raw = SecureStringToString(secure);
 0405    if (string.IsNullOrWhiteSpace(raw))
 0406      return;
 407
 408    try
 0409    {
 0410      var payload = JsonSerializer.Deserialize<BackupCodesPayload>(raw);
 0411      if (payload?.Items is null)
 0412        return;
 413
 0414      BackupCodes.Clear();
 0415      foreach (var entry in payload.Items.OrderBy(i => i.SortOrder))
 0416      {
 0417        BackupCodes.Add(new BackupCodeItemModel
 0418        {
 0419          Value = entry.Value,
 0420          IsUsed = entry.IsUsed,
 0421          UsedAt = entry.IsUsed ? entry.UsedAt : null
 0422        });
 0423      }
 0424    }
 0425    catch (JsonException)
 0426    {
 0427      foreach (var code in BackupCodeParser.Parse(raw))
 0428      {
 0429        BackupCodes.Add(new BackupCodeItemModel
 0430        {
 0431          Value = code,
 0432          IsUsed = false,
 0433        });
 0434      }
 0435    }
 0436  }
 437
 438  private static string SecureStringToString(SecureString s)
 0439  {
 0440    var ptr = System.Runtime.InteropServices.Marshal.SecureStringToGlobalAllocUnicode(s);
 0441    try { return System.Runtime.InteropServices.Marshal.PtrToStringUni(ptr) ?? string.Empty; }
 0442    finally { System.Runtime.InteropServices.Marshal.ZeroFreeGlobalAllocUnicode(ptr); }
 0443  }
 444
 445  private static async Task CopyToClipboardAsync(string text)
 0446  {
 0447    var clipboard = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassic
 0448      ? TopLevel.GetTopLevel(desktop.MainWindow)?.Clipboard
 0449      : null;
 0450    if (clipboard is null)
 0451      return;
 452
 0453    await clipboard.SetTextAsync(text);
 0454    _ = Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ =>
 0455      Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => await clipboard.ClearAsync()));
 0456  }
 457
 458  private sealed class BackupCodesPayload
 459  {
 3460    public List<BackupCodeEntry> Items { get; set; } = [];
 461  }
 462
 463  private sealed class BackupCodeEntry
 464  {
 6465    public string Value { get; set; } = string.Empty;
 4466    public bool IsUsed { get; set; }
 4467    public DateTime? UsedAt { get; set; }
 4468    public int SortOrder { get; set; }
 469  }
 470}
 471
 472public partial class BackupCodeItemModel : ObservableObject
 473{
 474  [ObservableProperty]
 475  private string _value = string.Empty;
 476
 477  [ObservableProperty]
 478  private bool _isUsed;
 479
 480  [ObservableProperty]
 481  private DateTime? _usedAt;
 482}