From 90b62d61aef64a0ab84ab869d7682f3af8ff10b3 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 9 Nov 2021 07:34:16 +1000 Subject: [PATCH] [Linked fields] Add Linked Field as a custom field type (#1563) * Add linked fields support * Fix style, don't show linked field if Secure Note * Finish basic linked fields for Login * Use Field.LinkedId to store linked field info * Reset Linked Custom Fields if cipherType changes * Refactor to use ItemView class * Use enum for LinkedId * Detect if no linkedFieldOptions --- src/App/Pages/Vault/AddEditPage.xaml | 10 ++++ src/App/Pages/Vault/AddEditPageViewModel.cs | 64 ++++++++++++++++++--- src/App/Pages/Vault/ViewPage.xaml | 6 ++ src/App/Pages/Vault/ViewPageViewModel.cs | 28 ++++++++- src/App/Resources/AppResources.Designer.cs | 16 +++++- src/App/Resources/AppResources.resx | 6 ++ src/Core/Enums/FieldType.cs | 3 +- src/Core/Enums/LinkedIdType.cs | 38 ++++++++++++ src/Core/Models/Api/FieldApi.cs | 1 + src/Core/Models/Data/FieldData.cs | 2 + src/Core/Models/Domain/Field.cs | 8 ++- src/Core/Models/Request/CipherRequest.cs | 3 +- src/Core/Models/View/CardView.cs | 19 +++++- src/Core/Models/View/CipherView.cs | 18 ++++-- src/Core/Models/View/FieldView.cs | 2 + src/Core/Models/View/IdentityView.cs | 32 ++++++++++- src/Core/Models/View/ItemView.cs | 14 +++++ src/Core/Models/View/LoginView.cs | 14 ++++- src/Core/Models/View/SecureNoteView.cs | 6 +- src/Core/Services/CipherService.cs | 3 +- 20 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 src/Core/Enums/LinkedIdType.cs create mode 100644 src/Core/Models/View/ItemView.cs diff --git a/src/App/Pages/Vault/AddEditPage.xaml b/src/App/Pages/Vault/AddEditPage.xaml index 08a99ad20..b630739fc 100644 --- a/src/App/Pages/Vault/AddEditPage.xaml +++ b/src/App/Pages/Vault/AddEditPage.xaml @@ -656,6 +656,16 @@ + + + (UriMatchType.Exact, AppResources.Exact), new KeyValuePair(UriMatchType.Never, AppResources.Never) }; - private List> _fieldTypeOptions = - new List> - { - new KeyValuePair(FieldType.Text, AppResources.FieldTypeText), - new KeyValuePair(FieldType.Hidden, AppResources.FieldTypeHidden), - new KeyValuePair(FieldType.Boolean, AppResources.FieldTypeBoolean) - }; public AddEditPageViewModel() { @@ -667,8 +660,20 @@ namespace Bit.App.Pages public async void AddField() { + var fieldTypeOptions = new List> + { + new KeyValuePair(FieldType.Text, AppResources.FieldTypeText), + new KeyValuePair(FieldType.Hidden, AppResources.FieldTypeHidden), + new KeyValuePair(FieldType.Boolean, AppResources.FieldTypeBoolean), + }; + + if (Cipher.LinkedFieldOptions != null) + { + fieldTypeOptions.Add(new KeyValuePair(FieldType.Linked, AppResources.FieldTypeLinked)); + } + var typeSelection = await Page.DisplayActionSheet(AppResources.SelectTypeField, AppResources.Cancel, null, - _fieldTypeOptions.Select(f => f.Value).ToArray()); + fieldTypeOptions.Select(f => f.Value).ToArray()); if (typeSelection != null && typeSelection != AppResources.Cancel) { var name = await _deviceActionService.DisplayPromptAync(AppResources.CustomFieldName); @@ -680,7 +685,7 @@ namespace Bit.App.Pages { Fields = new ExtendedObservableCollection(); } - var type = _fieldTypeOptions.FirstOrDefault(f => f.Value == typeSelection).Key; + var type = fieldTypeOptions.FirstOrDefault(f => f.Value == typeSelection).Key; Fields.Add(new AddEditPageFieldViewModel(Cipher, new FieldView { Type = type, @@ -746,6 +751,12 @@ namespace Bit.App.Pages { Cipher.Type = TypeOptions[TypeSelectedIndex].Value; TriggerCipherChanged(); + + // Linked Custom Fields only apply to a specific item type + foreach (var field in Fields.Where(f => f.IsLinkedType).ToList()) + { + Fields.Remove(field); + } } } @@ -850,23 +861,29 @@ namespace Bit.App.Pages public class AddEditPageFieldViewModel : ExtendedViewModel { + private II18nService _i18nService; private FieldView _field; private CipherView _cipher; private bool _showHiddenValue; private bool _booleanValue; + private int _linkedFieldOptionSelectedIndex; private string[] _additionalFieldProperties = new string[] { nameof(IsBooleanType), nameof(IsHiddenType), nameof(IsTextType), + nameof(IsLinkedType), }; public AddEditPageFieldViewModel(CipherView cipher, FieldView field) { + _i18nService = ServiceContainer.Resolve("i18nService"); _cipher = cipher; Field = field; ToggleHiddenValueCommand = new Command(ToggleHiddenValue); BooleanValue = IsBooleanType && field.Value == "true"; + LinkedFieldOptionSelectedIndex = !Field.LinkedId.HasValue ? 0 : + LinkedFieldOptions.FindIndex(lfo => lfo.Value == Field.LinkedId.Value); } public FieldView Field @@ -898,12 +915,32 @@ namespace Bit.App.Pages } } + public int LinkedFieldOptionSelectedIndex + { + get => _linkedFieldOptionSelectedIndex; + set + { + if (SetProperty(ref _linkedFieldOptionSelectedIndex, value)) + { + LinkedFieldValueChanged(); + } + } + } + + public List> LinkedFieldOptions + { + get => _cipher.LinkedFieldOptions + .Select(kvp => new KeyValuePair(_i18nService.T(kvp.Key), kvp.Value)) + .ToList(); + } + public Command ToggleHiddenValueCommand { get; set; } public string ShowHiddenValueIcon => _showHiddenValue ? "" : ""; public bool IsTextType => _field.Type == FieldType.Text; public bool IsBooleanType => _field.Type == FieldType.Boolean; public bool IsHiddenType => _field.Type == FieldType.Hidden; + public bool IsLinkedType => _field.Type == FieldType.Linked; public bool ShowViewHidden => IsHiddenType && (_cipher.ViewPassword || _field.NewField); public void ToggleHiddenValue() @@ -920,5 +957,14 @@ namespace Bit.App.Pages { TriggerPropertyChanged(nameof(Field), _additionalFieldProperties); } + + private void LinkedFieldValueChanged() + { + if (Field != null && LinkedFieldOptionSelectedIndex > -1) + { + Field.LinkedId = LinkedFieldOptions.Find(lfo => + lfo.Value == LinkedFieldOptions[LinkedFieldOptionSelectedIndex].Value).Value; + } + } } } diff --git a/src/App/Pages/Vault/ViewPage.xaml b/src/App/Pages/Vault/ViewPage.xaml index d9607ba19..d106d3c16 100644 --- a/src/App/Pages/Vault/ViewPage.xaml +++ b/src/App/Pages/Vault/ViewPage.xaml @@ -561,6 +561,12 @@ Grid.Row="1" Grid.Column="0" IsVisible="{Binding IsTextType}" /> + ("i18nService"); _vm = vm; _cipher = cipher; Field = field; @@ -741,16 +743,38 @@ namespace Bit.App.Pages }); } + public string ValueText + { + get + { + if (IsBooleanType) + { + return _field.Value == "true" ? "" : ""; + } + else if (IsLinkedType) + { + var i18nKey = _cipher.LinkedFieldI18nKey(Field.LinkedId.GetValueOrDefault()); + return " " + _i18nService.T(i18nKey); + } + else + { + return _field.Value; + } + } + } + public Command ToggleHiddenValueCommand { get; set; } - public string ValueText => IsBooleanType ? (_field.Value == "true" ? "" : "") : _field.Value; public string ShowHiddenValueIcon => _showHiddenValue ? "" : ""; public bool IsTextType => _field.Type == Core.Enums.FieldType.Text; public bool IsBooleanType => _field.Type == Core.Enums.FieldType.Boolean; public bool IsHiddenType => _field.Type == Core.Enums.FieldType.Hidden; + public bool IsLinkedType => _field.Type == Core.Enums.FieldType.Linked; public bool ShowViewHidden => IsHiddenType && _cipher.ViewPassword; public bool ShowCopyButton => _field.Type != Core.Enums.FieldType.Boolean && - !string.IsNullOrWhiteSpace(_field.Value) && !(IsHiddenType && !_cipher.ViewPassword); + !string.IsNullOrWhiteSpace(_field.Value) && + !(IsHiddenType && !_cipher.ViewPassword) && + _field.Type != FieldType.Linked; public async void ToggleHiddenValue() { diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index c2f92a336..6d9c3bd3b 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,9 +10,10 @@ namespace Bit.App.Resources { using System; + using System.Reflection; - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class AppResources { @@ -1779,6 +1781,12 @@ namespace Bit.App.Resources { } } + public static string FullName { + get { + return ResourceManager.GetString("FullName", resourceCulture); + } + } + public static string LicenseNumber { get { return ResourceManager.GetString("LicenseNumber", resourceCulture); @@ -2025,6 +2033,12 @@ namespace Bit.App.Resources { } } + public static string FieldTypeLinked { + get { + return ResourceManager.GetString("FieldTypeLinked", resourceCulture); + } + } + public static string FieldTypeText { get { return ResourceManager.GetString("FieldTypeText", resourceCulture); diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 5a3dd740c..8d0047675 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -1059,6 +1059,9 @@ Last Name + + Full Name + License Number @@ -1183,6 +1186,9 @@ Hidden + + Linked + Text diff --git a/src/Core/Enums/FieldType.cs b/src/Core/Enums/FieldType.cs index bf30d4f12..5eef485b7 100644 --- a/src/Core/Enums/FieldType.cs +++ b/src/Core/Enums/FieldType.cs @@ -4,6 +4,7 @@ { Text = 0, Hidden = 1, - Boolean = 2 + Boolean = 2, + Linked = 3, } } diff --git a/src/Core/Enums/LinkedIdType.cs b/src/Core/Enums/LinkedIdType.cs new file mode 100644 index 000000000..32803868c --- /dev/null +++ b/src/Core/Enums/LinkedIdType.cs @@ -0,0 +1,38 @@ +namespace Bit.Core.Enums { + + public enum LinkedIdType: int + { + // Login + Login_Username = 100, + Login_Password = 101, + + // Card + Card_CardholderName = 300, + Card_ExpMonth = 301, + Card_ExpYear = 302, + Card_Code = 303, + Card_Brand = 304, + Card_Number = 305, + + // Identity + Identity_Title = 400, + Identity_MiddleName = 401, + Identity_Address1 = 402, + Identity_Address2 = 403, + Identity_Address3 = 404, + Identity_City = 405, + Identity_State = 406, + Identity_PostalCode = 407, + Identity_Country = 408, + Identity_Company = 409, + Identity_Email = 410, + Identity_Phone = 411, + Identity_Ssn = 412, + Identity_Username = 413, + Identity_PassportNumber = 414, + Identity_LicenseNumber = 415, + Identity_FirstName = 416, + Identity_LastName = 417, + Identity_FullName = 418, + } +} diff --git a/src/Core/Models/Api/FieldApi.cs b/src/Core/Models/Api/FieldApi.cs index 8fca5ac29..524d13ecf 100644 --- a/src/Core/Models/Api/FieldApi.cs +++ b/src/Core/Models/Api/FieldApi.cs @@ -7,5 +7,6 @@ namespace Bit.Core.Models.Api public FieldType Type { get; set; } public string Name { get; set; } public string Value { get; set; } + public LinkedIdType? LinkedId { get; set; } } } diff --git a/src/Core/Models/Data/FieldData.cs b/src/Core/Models/Data/FieldData.cs index 9e7bf67e9..bf898e156 100644 --- a/src/Core/Models/Data/FieldData.cs +++ b/src/Core/Models/Data/FieldData.cs @@ -12,10 +12,12 @@ namespace Bit.Core.Models.Data Type = data.Type; Name = data.Name; Value = data.Value; + LinkedId = data.LinkedId; } public FieldType Type { get; set; } public string Name { get; set; } public string Value { get; set; } + public LinkedIdType? LinkedId { get; set; } } } diff --git a/src/Core/Models/Domain/Field.cs b/src/Core/Models/Domain/Field.cs index 061a6409c..31f5d4340 100644 --- a/src/Core/Models/Domain/Field.cs +++ b/src/Core/Models/Domain/Field.cs @@ -19,12 +19,14 @@ namespace Bit.Core.Models.Domain public Field(FieldData obj, bool alreadyEncrypted = false) { Type = obj.Type; + LinkedId = obj.LinkedId; BuildDomainModel(this, obj, _map, alreadyEncrypted); } public EncString Name { get; set; } public EncString Value { get; set; } public FieldType Type { get; set; } + public LinkedIdType? LinkedId { get; set; } public Task DecryptAsync(string orgId) { @@ -38,10 +40,12 @@ namespace Bit.Core.Models.Domain { "Name", "Value", - "Type" + "Type", + "LinkedId" }, new HashSet { - "Type" + "Type", + "LinkedId" }); return f; } diff --git a/src/Core/Models/Request/CipherRequest.cs b/src/Core/Models/Request/CipherRequest.cs index 3e6e1f8e8..ca00ee182 100644 --- a/src/Core/Models/Request/CipherRequest.cs +++ b/src/Core/Models/Request/CipherRequest.cs @@ -81,7 +81,8 @@ namespace Bit.Core.Models.Request { Type = f.Type, Name = f.Name?.EncryptedString, - Value = f.Value?.EncryptedString + Value = f.Value?.EncryptedString, + LinkedId = f.LinkedId, }).ToList(); PasswordHistory = cipher.PasswordHistory?.Select(ph => new PasswordHistoryRequest diff --git a/src/Core/Models/View/CardView.cs b/src/Core/Models/View/CardView.cs index cb92ae161..421d0b712 100644 --- a/src/Core/Models/View/CardView.cs +++ b/src/Core/Models/View/CardView.cs @@ -1,9 +1,11 @@ using Bit.Core.Models.Domain; +using Bit.Core.Enums; +using System.Collections.Generic; using System.Text.RegularExpressions; namespace Bit.Core.Models.View { - public class CardView : View + public class CardView : ItemView { private string _brand; private string _number; @@ -40,7 +42,7 @@ namespace Bit.Core.Models.View } } - public string SubTitle + public override string SubTitle { get { @@ -82,6 +84,19 @@ namespace Bit.Core.Models.View } } + public override List> LinkedFieldOptions + { + get => new List>() + { + new KeyValuePair("CardholderName", LinkedIdType.Card_CardholderName), + new KeyValuePair("ExpirationMonth", LinkedIdType.Card_ExpMonth), + new KeyValuePair("ExpirationYear", LinkedIdType.Card_ExpYear), + new KeyValuePair("SecurityCode", LinkedIdType.Card_Code), + new KeyValuePair("Brand", LinkedIdType.Card_Brand), + new KeyValuePair("Number", LinkedIdType.Card_Number), + }; + } + private string FormatYear(string year) { return year.Length == 2 ? string.Concat("20", year) : year; diff --git a/src/Core/Models/View/CipherView.cs b/src/Core/Models/View/CipherView.cs index f28328140..1d238f271 100644 --- a/src/Core/Models/View/CipherView.cs +++ b/src/Core/Models/View/CipherView.cs @@ -50,20 +50,20 @@ namespace Bit.Core.Models.View public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } - public string SubTitle + public ItemView Item { get { switch (Type) { case CipherType.Login: - return Login.SubTitle; + return Login; case CipherType.SecureNote: - return SecureNote.SubTitle; + return SecureNote; case CipherType.Card: - return Card.SubTitle; + return Card; case CipherType.Identity: - return Identity.SubTitle; + return Identity; default: break; } @@ -71,6 +71,8 @@ namespace Bit.Core.Models.View } } + public List> LinkedFieldOptions => Item.LinkedFieldOptions; + public string SubTitle => Item.SubTitle; public bool Shared => OrganizationId != null; public bool HasPasswordHistory => PasswordHistory?.Any() ?? false; public bool HasAttachments => Attachments?.Any() ?? false; @@ -102,5 +104,11 @@ namespace Bit.Core.Models.View } } public bool IsDeleted => DeletedDate.HasValue; + + public string LinkedFieldI18nKey(LinkedIdType id) + { + return LinkedFieldOptions.Find(lfo => lfo.Value == id).Key; + } + } } diff --git a/src/Core/Models/View/FieldView.cs b/src/Core/Models/View/FieldView.cs index 307112564..2c3e0ba82 100644 --- a/src/Core/Models/View/FieldView.cs +++ b/src/Core/Models/View/FieldView.cs @@ -10,6 +10,7 @@ namespace Bit.Core.Models.View public FieldView(Field f) { Type = f.Type; + LinkedId = f.LinkedId; } public string Name { get; set; } @@ -17,5 +18,6 @@ namespace Bit.Core.Models.View public FieldType Type { get; set; } public string MaskedValue => Value != null ? "••••••••" : null; public bool NewField { get; set; } + public LinkedIdType? LinkedId { get; set; } } } diff --git a/src/Core/Models/View/IdentityView.cs b/src/Core/Models/View/IdentityView.cs index a6de1f86a..d4dae2922 100644 --- a/src/Core/Models/View/IdentityView.cs +++ b/src/Core/Models/View/IdentityView.cs @@ -1,8 +1,10 @@ using Bit.Core.Models.Domain; +using Bit.Core.Enums; +using System.Collections.Generic; namespace Bit.Core.Models.View { - public class IdentityView : View + public class IdentityView : ItemView { private string _firstName; private string _lastName; @@ -47,7 +49,7 @@ namespace Bit.Core.Models.View public string PassportNumber { get; set; } public string LicenseNumber { get; set; } - public string SubTitle + public override string SubTitle { get { @@ -141,5 +143,31 @@ namespace Bit.Core.Models.View return string.Format("{0}, {1}, {2}", city, state, postalCode); } } + + public override List> LinkedFieldOptions + { + get => new List>() + { + new KeyValuePair("Title", LinkedIdType.Identity_Title), + new KeyValuePair("MiddleName", LinkedIdType.Identity_MiddleName), + new KeyValuePair("Address1", LinkedIdType.Identity_Address1), + new KeyValuePair("Address2", LinkedIdType.Identity_Address2), + new KeyValuePair("Address3", LinkedIdType.Identity_Address3), + new KeyValuePair("CityTown", LinkedIdType.Identity_City), + new KeyValuePair("StateProvince", LinkedIdType.Identity_State), + new KeyValuePair("ZipPostalCode", LinkedIdType.Identity_PostalCode), + new KeyValuePair("Country", LinkedIdType.Identity_Country), + new KeyValuePair("Company", LinkedIdType.Identity_Company), + new KeyValuePair("Email", LinkedIdType.Identity_Email), + new KeyValuePair("Phone", LinkedIdType.Identity_Phone), + new KeyValuePair("SSN", LinkedIdType.Identity_Ssn), + new KeyValuePair("Username", LinkedIdType.Identity_Username), + new KeyValuePair("PassportNumber", LinkedIdType.Identity_PassportNumber), + new KeyValuePair("LicenseNumber", LinkedIdType.Identity_LicenseNumber), + new KeyValuePair("FirstName", LinkedIdType.Identity_FirstName), + new KeyValuePair("LastName", LinkedIdType.Identity_LastName), + new KeyValuePair("FullName", LinkedIdType.Identity_FullName), + }; + } } } diff --git a/src/Core/Models/View/ItemView.cs b/src/Core/Models/View/ItemView.cs new file mode 100644 index 000000000..e0e6fba13 --- /dev/null +++ b/src/Core/Models/View/ItemView.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Bit.Core.Enums; + +namespace Bit.Core.Models.View +{ + public abstract class ItemView : View + { + public ItemView() { } + + public abstract string SubTitle { get; } + + public abstract List> LinkedFieldOptions { get; } + } +} diff --git a/src/Core/Models/View/LoginView.cs b/src/Core/Models/View/LoginView.cs index e0d0ba3b5..595c69928 100644 --- a/src/Core/Models/View/LoginView.cs +++ b/src/Core/Models/View/LoginView.cs @@ -1,11 +1,12 @@ using Bit.Core.Models.Domain; +using Bit.Core.Enums; using System; using System.Collections.Generic; using System.Linq; namespace Bit.Core.Models.View { - public class LoginView : View + public class LoginView : ItemView { public LoginView() { } @@ -21,9 +22,18 @@ namespace Bit.Core.Models.View public List Uris { get; set; } public string Uri => HasUris ? Uris[0].Uri : null; public string MaskedPassword => Password != null ? "••••••••" : null; - public string SubTitle => Username; + public override string SubTitle => Username; public bool CanLaunch => HasUris && Uris.Any(u => u.CanLaunch); public string LaunchUri => HasUris ? Uris.FirstOrDefault(u => u.CanLaunch)?.LaunchUri : null; public bool HasUris => (Uris?.Count ?? 0) > 0; + + public override List> LinkedFieldOptions + { + get => new List>() + { + new KeyValuePair("Username", LinkedIdType.Login_Username), + new KeyValuePair("Password", LinkedIdType.Login_Password), + }; + } } } diff --git a/src/Core/Models/View/SecureNoteView.cs b/src/Core/Models/View/SecureNoteView.cs index 694f8ba6e..67f3818df 100644 --- a/src/Core/Models/View/SecureNoteView.cs +++ b/src/Core/Models/View/SecureNoteView.cs @@ -1,9 +1,10 @@ using Bit.Core.Enums; using Bit.Core.Models.Domain; +using System.Collections.Generic; namespace Bit.Core.Models.View { - public class SecureNoteView : View + public class SecureNoteView : ItemView { public SecureNoteView() { } @@ -13,6 +14,7 @@ namespace Bit.Core.Models.View } public SecureNoteType Type { get; set; } - public string SubTitle => null; + public override string SubTitle => null; + public override List> LinkedFieldOptions => null; } } diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index 72b102eea..a0d145709 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -1190,7 +1190,8 @@ namespace Bit.Core.Services { var field = new Field { - Type = model.Type + Type = model.Type, + LinkedId = model.LinkedId, }; // normalize boolean type field values if (model.Type == FieldType.Boolean && model.Value != "true")