< Summary

Information
Class: LOCKnet.App.ViewModels.BackupCodeItemModel
Assembly: LOCKnet.App
File(s): /home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.App/ViewModels/CredentialDetailViewModel.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 482
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%

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;
 21  private readonly IPasswordGeneratorService _generator = new PasswordGeneratorService();
 22  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))]
 31  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  {
 36    get => CredentialType == CredentialType.Password;
 37    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  {
 43    get => CredentialType == CredentialType.ApiKey;
 44    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  {
 50    get => CredentialType == CredentialType.BackupCodes;
 51    set { if (value) CredentialType = CredentialType.BackupCodes; }
 52  }
 53
 54  /// <summary>Dynamisches Label fuer das Geheimnis-Feld je nach Credential-Typ.</summary>
 55  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>
 58  public string SecretFieldWatermark => CredentialType == CredentialType.ApiKey
 59    ? (IsEditMode ? "Leer lassen, um aktuellen API-Schlüssel zu behalten" : "API-Schlüssel eingeben")
 60    : (IsEditMode ? "Leer lassen, um aktuelles Passwort zu behalten" : "Passwort eingeben");
 61
 62  /// <summary>Dynamisches Label fuer das Benutzername-Feld je nach Credential-Typ.</summary>
 63  public string UsernameLabel => CredentialType switch
 64  {
 65    CredentialType.ApiKey => "Client-ID / Bezeichner",
 66    CredentialType.BackupCodes => "Account / E-Mail",
 67    _ => "Benutzername"
 68  };
 69  [ObservableProperty]
 70  [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
 71  private string _title = string.Empty;
 72
 73  [ObservableProperty]
 74  private string _username = string.Empty;
 75
 76  [ObservableProperty]
 77  [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
 78  private string _password = string.Empty;
 79
 80  [ObservableProperty]
 81  private string _url = string.Empty;
 82
 83  [ObservableProperty]
 84  private string _notes = string.Empty;
 85
 86  [ObservableProperty]
 87  private string _errorMessage = string.Empty;
 88
 89  [ObservableProperty]
 90  private bool _isPasswordVisible;
 91
 92  [ObservableProperty]
 93  private string _iconKey = string.Empty;
 94
 95  [ObservableProperty]
 96  private bool _useUppercase = true;
 97
 98  [ObservableProperty]
 99  private bool _useLowercase = true;
 100
 101  [ObservableProperty]
 102  private bool _useDigits = true;
 103
 104  [ObservableProperty]
 105  private bool _useSpecial = true;
 106
 107  [ObservableProperty]
 108  private int _passwordLength = 16;
 109
 110  [ObservableProperty]
 111  private int _strengthScore;
 112
 113  [ObservableProperty]
 114  private string _strengthLabel = string.Empty;
 115
 116  [ObservableProperty]
 117  private string _strengthColor = "#50506C";
 118
 119  [ObservableProperty]
 120  private string _strengthSeg1 = "#2E3460";
 121
 122  [ObservableProperty]
 123  private string _strengthSeg2 = "#2E3460";
 124
 125  [ObservableProperty]
 126  private string _strengthSeg3 = "#2E3460";
 127
 128  [ObservableProperty]
 129  private string _strengthSeg4 = "#2E3460";
 130
 131  [ObservableProperty]
 132  private string _strengthSeg5 = "#2E3460";
 133
 134  [ObservableProperty]
 135  private bool _showGenerator;
 136
 137  [ObservableProperty]
 138  private string _backupCodesInput = string.Empty;
 139
 140  [ObservableProperty]
 141  private bool _showUsedBackupCodes = true;
 142
 143  public ObservableCollection<BackupCodeItemModel> BackupCodes { get; } = [];
 144
 145  public bool IsEditMode => _editId.HasValue;
 146  public string WindowTitle => IsEditMode ? "Credential bearbeiten" : "Neues Credential";
 147
 148  // ── Konstruktor (neu) ─────────────────────────────────────────────────────
 149  public CredentialDetailViewModel()
 150  {
 151    UpdateStrengthDetails(Password);
 152  }
 153
 154  // ── Konstruktor (bearbeiten) ───────────────────────────────────────────────
 155  public CredentialDetailViewModel(CredentialRecord record)
 156  {
 157    _editId = record.Id;
 158    Title = record.Title;
 159    Username = record.Username ?? string.Empty;
 160    Url = record.Url ?? string.Empty;
 161    Notes = record.Notes ?? string.Empty;
 162    IconKey = record.IconKey ?? string.Empty;
 163    CredentialType = record.CredentialType;
 164    if (CredentialType == CredentialType.BackupCodes)
 165      LoadBackupCodes(record.Id);
 166
 167    UpdateStrengthDetails(Password);
 168  }
 169
 170  // ── Commands ──────────────────────────────────────────────────────────────
 171
 172  [RelayCommand(CanExecute = nameof(CanSave))]
 173  private void Save()
 174  {
 175    ErrorMessage = string.Empty;
 176    try
 177    {
 178      if (CredentialType == CredentialType.BackupCodes)
 179      {
 180        if (BackupCodes.Count == 0)
 181        {
 182          ErrorMessage = "Mindestens ein Backup-Code ist erforderlich.";
 183          return;
 184        }
 185
 186        Password = JsonSerializer.Serialize(new BackupCodesPayload
 187        {
 188          Items = BackupCodes.Select((item, index) => new BackupCodeEntry
 189          {
 190            Value = item.Value,
 191            IsUsed = item.IsUsed,
 192            UsedAt = item.IsUsed ? item.UsedAt : null,
 193            SortOrder = index,
 194          }).ToList()
 195        });
 196      }
 197
 198      var secure = string.IsNullOrEmpty(Password) ? null : ToSecureString(Password);
 199
 200      if (IsEditMode)
 201      {
 202        AppServices.Current.CredentialService.Update(
 203          _editId!.Value, Title,
 204          string.IsNullOrWhiteSpace(Username) ? null : Username,
 205          secure,
 206          string.IsNullOrWhiteSpace(Url) ? null : Url,
 207          string.IsNullOrWhiteSpace(Notes) ? null : Notes,
 208          string.IsNullOrWhiteSpace(IconKey) ? null : IconKey,
 209          CredentialType);
 210      }
 211      else
 212      {
 213        if (secure is null)
 214        {
 215          ErrorMessage = CredentialType == CredentialType.ApiKey ? "API-Schlüssel ist erforderlich." : "Passwort ist erf
 216          return;
 217        }
 218
 219        AppServices.Current.CredentialService.Add(
 220          Title,
 221          string.IsNullOrWhiteSpace(Username) ? null : Username,
 222          secure,
 223          string.IsNullOrWhiteSpace(Url) ? null : Url,
 224          string.IsNullOrWhiteSpace(Notes) ? null : Notes,
 225          string.IsNullOrWhiteSpace(IconKey) ? null : IconKey,
 226          CredentialType);
 227      }
 228
 229      SaveCompleted?.Invoke(this, EventArgs.Empty);
 230    }
 231    catch (Exception ex)
 232    {
 233      ErrorMessage = ex.Message;
 234    }
 235  }
 236
 237  private bool CanSave()
 238  {
 239    if (string.IsNullOrWhiteSpace(Title))
 240      return false;
 241
 242    if (CredentialType == CredentialType.BackupCodes)
 243      return BackupCodes.Count > 0;
 244
 245    return IsEditMode || Password.Length > 0;
 246  }
 247
 248  [RelayCommand]
 249  private void ImportBackupCodes()
 250  {
 251    foreach (var code in BackupCodeParser.Parse(BackupCodesInput))
 252    {
 253      if (BackupCodes.Any(x => string.Equals(x.Value, code, StringComparison.OrdinalIgnoreCase)))
 254        continue;
 255
 256      BackupCodes.Add(new BackupCodeItemModel
 257      {
 258        Value = code,
 259        IsUsed = false,
 260      });
 261    }
 262
 263    BackupCodesInput = string.Empty;
 264    SaveCommand.NotifyCanExecuteChanged();
 265  }
 266
 267  [RelayCommand]
 268  private void ToggleBackupCodeUsed(BackupCodeItemModel? item)
 269  {
 270    if (item is null)
 271      return;
 272
 273    item.IsUsed = !item.IsUsed;
 274    item.UsedAt = item.IsUsed ? DateTime.UtcNow : null;
 275  }
 276
 277  [RelayCommand]
 278  private void RemoveBackupCode(BackupCodeItemModel? item)
 279  {
 280    if (item is null)
 281      return;
 282
 283    BackupCodes.Remove(item);
 284    SaveCommand.NotifyCanExecuteChanged();
 285  }
 286
 287  [RelayCommand]
 288  private void CopyBackupCode(BackupCodeItemModel? item)
 289  {
 290    if (item is null)
 291      return;
 292
 293    _ = CopyToClipboardAsync(item.Value);
 294  }
 295
 296  [RelayCommand]
 297  private void CopyActiveBackupCodes()
 298  {
 299    var text = string.Join(Environment.NewLine, BackupCodes.Where(c => !c.IsUsed).Select(c => c.Value));
 300    if (text.Length > 0)
 301      _ = CopyToClipboardAsync(text);
 302  }
 303
 304  [RelayCommand]
 305  private void CopyAllBackupCodes()
 306  {
 307    var text = string.Join(Environment.NewLine, BackupCodes.Select(c => c.Value));
 308    if (text.Length > 0)
 309      _ = CopyToClipboardAsync(text);
 310  }
 311
 312  [RelayCommand]
 313  private void Cancel() => Cancelled?.Invoke(this, EventArgs.Empty);
 314
 315  [RelayCommand]
 316  private void TogglePasswordVisibility() => IsPasswordVisible = !IsPasswordVisible;
 317
 318  [RelayCommand]
 319  private void ToggleGenerator() => ShowGenerator = !ShowGenerator;
 320
 321  [RelayCommand]
 322  private void GeneratePassword()
 323  {
 324    var generated = _generator.Generate(new PasswordGeneratorOptions
 325    {
 326      Length = PasswordLength,
 327      UseUppercase = UseUppercase,
 328      UseLowercase = UseLowercase,
 329      UseDigits = UseDigits,
 330      UseSpecial = UseSpecial
 331    });
 332
 333    Password = generated;
 334    ShowGenerator = false;
 335  }
 336
 337  [RelayCommand]
 338  private void SetIcon(string key) => IconKey = key;
 339
 340  partial void OnCredentialTypeChanged(CredentialType value)
 341  {
 342    if (value != CredentialType.BackupCodes)
 343      BackupCodesInput = string.Empty;
 344
 345    SaveCommand.NotifyCanExecuteChanged();
 346  }
 347
 348  partial void OnPasswordChanged(string value) => UpdateStrengthDetails(value);
 349
 350  partial void OnStrengthScoreChanged(int value)
 351  {
 352    var litSegments = GetLitStrengthSegments();
 353    StrengthSeg1 = litSegments >= 1 ? StrengthColor : "#2E3460";
 354    StrengthSeg2 = litSegments >= 2 ? StrengthColor : "#2E3460";
 355    StrengthSeg3 = litSegments >= 3 ? StrengthColor : "#2E3460";
 356    StrengthSeg4 = litSegments >= 4 ? StrengthColor : "#2E3460";
 357    StrengthSeg5 = litSegments >= 5 ? StrengthColor : "#2E3460";
 358  }
 359
 360  partial void OnStrengthColorChanged(string value)
 361  {
 362    var litSegments = GetLitStrengthSegments();
 363    StrengthSeg1 = litSegments >= 1 ? value : "#2E3460";
 364    StrengthSeg2 = litSegments >= 2 ? value : "#2E3460";
 365    StrengthSeg3 = litSegments >= 3 ? value : "#2E3460";
 366    StrengthSeg4 = litSegments >= 4 ? value : "#2E3460";
 367    StrengthSeg5 = litSegments >= 5 ? value : "#2E3460";
 368  }
 369
 370  // ── Helpers ───────────────────────────────────────────────────────────────
 371
 372  private static SecureString ToSecureString(string s)
 373  {
 374    var secure = new SecureString();
 375    foreach (var c in s) secure.AppendChar(c);
 376    secure.MakeReadOnly();
 377    return secure;
 378  }
 379
 380  private void UpdateStrengthDetails(string password)
 381  {
 382    var strength = _strengthService.Evaluate(password);
 383    StrengthScore = strength.Score;
 384    StrengthLabel = strength.Label;
 385    StrengthColor = strength.Color;
 386  }
 387
 388  private int GetLitStrengthSegments()
 389  {
 390    if (string.IsNullOrEmpty(Password))
 391    {
 392      return 0;
 393    }
 394
 395    return Math.Clamp(StrengthScore + 1, 1, 5);
 396  }
 397
 398  private void LoadBackupCodes(int id)
 399  {
 400    var secure = AppServices.Current.CredentialService.GetPassword(id);
 401    if (secure is null)
 402      return;
 403
 404    var raw = SecureStringToString(secure);
 405    if (string.IsNullOrWhiteSpace(raw))
 406      return;
 407
 408    try
 409    {
 410      var payload = JsonSerializer.Deserialize<BackupCodesPayload>(raw);
 411      if (payload?.Items is null)
 412        return;
 413
 414      BackupCodes.Clear();
 415      foreach (var entry in payload.Items.OrderBy(i => i.SortOrder))
 416      {
 417        BackupCodes.Add(new BackupCodeItemModel
 418        {
 419          Value = entry.Value,
 420          IsUsed = entry.IsUsed,
 421          UsedAt = entry.IsUsed ? entry.UsedAt : null
 422        });
 423      }
 424    }
 425    catch (JsonException)
 426    {
 427      foreach (var code in BackupCodeParser.Parse(raw))
 428      {
 429        BackupCodes.Add(new BackupCodeItemModel
 430        {
 431          Value = code,
 432          IsUsed = false,
 433        });
 434      }
 435    }
 436  }
 437
 438  private static string SecureStringToString(SecureString s)
 439  {
 440    var ptr = System.Runtime.InteropServices.Marshal.SecureStringToGlobalAllocUnicode(s);
 441    try { return System.Runtime.InteropServices.Marshal.PtrToStringUni(ptr) ?? string.Empty; }
 442    finally { System.Runtime.InteropServices.Marshal.ZeroFreeGlobalAllocUnicode(ptr); }
 443  }
 444
 445  private static async Task CopyToClipboardAsync(string text)
 446  {
 447    var clipboard = Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassic
 448      ? TopLevel.GetTopLevel(desktop.MainWindow)?.Clipboard
 449      : null;
 450    if (clipboard is null)
 451      return;
 452
 453    await clipboard.SetTextAsync(text);
 454    _ = Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ =>
 455      Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => await clipboard.ClearAsync()));
 456  }
 457
 458  private sealed class BackupCodesPayload
 459  {
 460    public List<BackupCodeEntry> Items { get; set; } = [];
 461  }
 462
 463  private sealed class BackupCodeEntry
 464  {
 465    public string Value { get; set; } = string.Empty;
 466    public bool IsUsed { get; set; }
 467    public DateTime? UsedAt { get; set; }
 468    public int SortOrder { get; set; }
 469  }
 470}
 471
 472public partial class BackupCodeItemModel : ObservableObject
 473{
 474  [ObservableProperty]
 2475  private string _value = string.Empty;
 476
 477  [ObservableProperty]
 478  private bool _isUsed;
 479
 480  [ObservableProperty]
 481  private DateTime? _usedAt;
 482}

Methods/Properties

.ctor()