From dbda39e10f1f06438832ad05c35681c5c28c1ca1 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 3 Nov 2021 08:03:37 +1000 Subject: [PATCH] Add Linked Field as custom field type (#431) * Basic proof of concept of Linked custom fields * Linked Fields for all cipher types, use dropdown * Move linkedFieldOptions to view models * Move add-edit custom fields to own component * Fix change handling if cipherType changes * Use Field.LinkedId to store linked field info * Refactor accessors in cipherView for type safety * Use map for linkedFieldOptions * Refactor: use decorators to record linkable info * Add ItemView * Use enums for linked field ids * Add union type for linkedId enums, add jsdoc comment * Use parameter properties for linkedFieldOption Co-authored-by: Matt Gibson * Fix type casting Co-authored-by: Matt Gibson --- .../add-edit-custom-fields.component.ts | 32 ++++++++++++++- common/src/enums/fieldType.ts | 1 + common/src/enums/linkedIdType.ts | 40 +++++++++++++++++++ .../src/misc/linkedFieldOption.decorator.ts | 28 +++++++++++++ common/src/models/api/fieldApi.ts | 3 ++ common/src/models/data/fieldData.ts | 3 ++ common/src/models/domain/field.ts | 6 ++- common/src/models/request/cipherRequest.ts | 1 + common/src/models/view/cardView.ts | 16 ++++++-- common/src/models/view/cipherView.ts | 34 +++++++++++++--- common/src/models/view/fieldView.ts | 3 ++ common/src/models/view/identityView.ts | 29 ++++++++++++-- common/src/models/view/itemView.ts | 8 ++++ common/src/models/view/loginView.ts | 13 +++++- common/src/models/view/secureNoteView.ts | 5 ++- common/src/services/cipher.service.ts | 1 + 16 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 common/src/enums/linkedIdType.ts create mode 100644 common/src/misc/linkedFieldOption.decorator.ts create mode 100644 common/src/models/view/itemView.ts diff --git a/angular/src/components/add-edit-custom-fields.component.ts b/angular/src/components/add-edit-custom-fields.component.ts index adf47f176e..8d37197096 100644 --- a/angular/src/components/add-edit-custom-fields.component.ts +++ b/angular/src/components/add-edit-custom-fields.component.ts @@ -1,6 +1,8 @@ import { Directive, Input, + OnChanges, + SimpleChanges, } from '@angular/core'; import { @@ -18,13 +20,17 @@ import { CipherType } from 'jslib-common/enums/cipherType'; import { EventType } from 'jslib-common/enums/eventType'; import { FieldType } from 'jslib-common/enums/fieldType'; +import { Utils } from 'jslib-common/misc/utils'; + @Directive() -export class AddEditCustomFieldsComponent { +export class AddEditCustomFieldsComponent implements OnChanges { @Input() cipher: CipherView; + @Input() thisCipherType: CipherType; @Input() editMode: boolean; addFieldType: FieldType = FieldType.Text; addFieldTypeOptions: any[]; + addFieldLinkedTypeOption: any; linkedFieldOptions: any[] = []; cipherType = CipherType; @@ -37,6 +43,13 @@ export class AddEditCustomFieldsComponent { { name: i18nService.t('cfTypeHidden'), value: FieldType.Hidden }, { name: i18nService.t('cfTypeBoolean'), value: FieldType.Boolean }, ]; + this.addFieldLinkedTypeOption = { name: this.i18nService.t('cfTypeLinked'), value: FieldType.Linked }; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.thisCipherType != null) { + this.setLinkedFieldOptions(); + } } addField() { @@ -48,6 +61,10 @@ export class AddEditCustomFieldsComponent { f.type = this.addFieldType; f.newField = true; + if (f.type === FieldType.Linked) { + f.linkedId = this.linkedFieldOptions[0].value; + } + this.cipher.fields.push(f); } @@ -73,4 +90,17 @@ export class AddEditCustomFieldsComponent { drop(event: CdkDragDrop) { moveItemInArray(this.cipher.fields, event.previousIndex, event.currentIndex); } + + private setLinkedFieldOptions() { + // Delete any Linked custom fields if the item type does not support them + if (this.cipher.linkedFieldOptions == null) { + this.cipher.fields = this.cipher.fields.filter(f => f.type !== FieldType.Linked); + return; + } + + const options: any = []; + this.cipher.linkedFieldOptions.forEach((linkedFieldOption, id) => + options.push({ name: this.i18nService.t(linkedFieldOption.i18nKey), value: id })); + this.linkedFieldOptions = options.sort(Utils.getSortFunction(this.i18nService, 'name')); + } } diff --git a/common/src/enums/fieldType.ts b/common/src/enums/fieldType.ts index c28b26c1da..594beba4d1 100644 --- a/common/src/enums/fieldType.ts +++ b/common/src/enums/fieldType.ts @@ -2,4 +2,5 @@ export enum FieldType { Text = 0, Hidden = 1, Boolean = 2, + Linked = 3, } diff --git a/common/src/enums/linkedIdType.ts b/common/src/enums/linkedIdType.ts new file mode 100644 index 0000000000..b2cd2f1ca8 --- /dev/null +++ b/common/src/enums/linkedIdType.ts @@ -0,0 +1,40 @@ +export type LinkedIdType = LoginLinkedId | CardLinkedId | IdentityLinkedId; + +// LoginView +export enum LoginLinkedId { + Username = 100, + Password = 101, +} + +// CardView +export enum CardLinkedId { + CardholderName = 300, + ExpMonth = 301, + ExpYear = 302, + Code = 303, + Brand = 304, + Number = 305, +} + +// IdentityView +export enum IdentityLinkedId { + Title = 400, + MiddleName = 401, + Address1 = 402, + Address2 = 403, + Address3 = 404, + City = 405, + State = 406, + PostalCode = 407, + Country = 408, + Company = 409, + Email = 410, + Phone = 411, + Ssn = 412, + Username = 413, + PassportNumber = 414, + LicenseNumber = 415, + FirstName = 416, + LastName = 417, + FullName = 418, +} diff --git a/common/src/misc/linkedFieldOption.decorator.ts b/common/src/misc/linkedFieldOption.decorator.ts new file mode 100644 index 0000000000..5bf05275ba --- /dev/null +++ b/common/src/misc/linkedFieldOption.decorator.ts @@ -0,0 +1,28 @@ +import { ItemView } from '../models/view/itemView'; + +import { LinkedIdType } from '../enums/linkedIdType'; + +export class LinkedMetadata { + constructor(readonly propertyKey: string, private readonly _i18nKey?: string) { } + + get i18nKey() { + return this._i18nKey ?? this.propertyKey; + } +} + +/** + * A decorator used to set metadata used by Linked custom fields. Apply it to a class property or getter to make it + * available as a Linked custom field option. + * @param id - A unique value that is saved in the Field model. It is used to look up the decorated class property. + * @param i18nKey - The i18n key used to describe the decorated class property in the UI. If it is null, then the name + * of the class property will be used as the i18n key. + */ +export function linkedFieldOption(id: LinkedIdType, i18nKey?: string) { + return (prototype: ItemView, propertyKey: string) => { + if (prototype.linkedFieldOptions == null) { + prototype.linkedFieldOptions = new Map(); + } + + prototype.linkedFieldOptions.set(id, new LinkedMetadata(propertyKey, i18nKey)); + }; +} diff --git a/common/src/models/api/fieldApi.ts b/common/src/models/api/fieldApi.ts index b7a024ccaa..b9e8e0e9da 100644 --- a/common/src/models/api/fieldApi.ts +++ b/common/src/models/api/fieldApi.ts @@ -1,11 +1,13 @@ import { BaseResponse } from '../response/baseResponse'; import { FieldType } from '../../enums/fieldType'; +import { LinkedIdType } from '../../enums/linkedIdType'; export class FieldApi extends BaseResponse { name: string; value: string; type: FieldType; + linkedId: LinkedIdType; constructor(data: any = null) { super(data); @@ -15,5 +17,6 @@ export class FieldApi extends BaseResponse { this.type = this.getResponseProperty('Type'); this.name = this.getResponseProperty('Name'); this.value = this.getResponseProperty('Value'); + this.linkedId = this.getResponseProperty('linkedId'); } } diff --git a/common/src/models/data/fieldData.ts b/common/src/models/data/fieldData.ts index d28d285b5c..e83445f2e0 100644 --- a/common/src/models/data/fieldData.ts +++ b/common/src/models/data/fieldData.ts @@ -1,4 +1,5 @@ import { FieldType } from '../../enums/fieldType'; +import { LinkedIdType } from '../../enums/linkedIdType'; import { FieldApi } from '../api/fieldApi'; @@ -6,6 +7,7 @@ export class FieldData { type: FieldType; name: string; value: string; + linkedId: LinkedIdType; constructor(response?: FieldApi) { if (response == null) { @@ -14,5 +16,6 @@ export class FieldData { this.type = response.type; this.name = response.name; this.value = response.value; + this.linkedId = response.linkedId; } } diff --git a/common/src/models/domain/field.ts b/common/src/models/domain/field.ts index 898939a2d5..7174797dfa 100644 --- a/common/src/models/domain/field.ts +++ b/common/src/models/domain/field.ts @@ -1,4 +1,5 @@ import { FieldType } from '../../enums/fieldType'; +import { LinkedIdType } from '../../enums/linkedIdType'; import { FieldData } from '../data/fieldData'; @@ -12,6 +13,7 @@ export class Field extends Domain { name: EncString; value: EncString; type: FieldType; + linkedId: LinkedIdType; constructor(obj?: FieldData, alreadyEncrypted: boolean = false) { super(); @@ -20,6 +22,7 @@ export class Field extends Domain { } this.type = obj.type; + this.linkedId = obj.linkedId; this.buildDomainModel(this, obj, { name: null, value: null, @@ -39,7 +42,8 @@ export class Field extends Domain { name: null, value: null, type: null, - }, ['type']); + linkedId: null, + }, ['type', 'linkedId']); return f; } } diff --git a/common/src/models/request/cipherRequest.ts b/common/src/models/request/cipherRequest.ts index 17068c0929..23f09462a6 100644 --- a/common/src/models/request/cipherRequest.ts +++ b/common/src/models/request/cipherRequest.ts @@ -119,6 +119,7 @@ export class CipherRequest { field.type = f.type; field.name = f.name ? f.name.encryptedString : null; field.value = f.value ? f.value.encryptedString : null; + field.linkedId = f.linkedId; return field; }); } diff --git a/common/src/models/view/cardView.ts b/common/src/models/view/cardView.ts index db8740793c..94e7ae491c 100644 --- a/common/src/models/view/cardView.ts +++ b/common/src/models/view/cardView.ts @@ -1,11 +1,19 @@ -import { View } from './view'; +import { ItemView } from './itemView'; import { Card } from '../domain/card'; -export class CardView implements View { +import { CardLinkedId as LinkedId } from '../../enums/linkedIdType'; + +import { linkedFieldOption } from '../../misc/linkedFieldOption.decorator'; + +export class CardView extends ItemView { + @linkedFieldOption(LinkedId.CardholderName) cardholderName: string = null; + @linkedFieldOption(LinkedId.ExpMonth, 'expirationMonth') expMonth: string = null; + @linkedFieldOption(LinkedId.ExpYear, 'expirationYear') expYear: string = null; + @linkedFieldOption(LinkedId.Code, 'securityCode') code: string = null; // tslint:disable @@ -15,7 +23,7 @@ export class CardView implements View { // tslint:enable constructor(c?: Card) { - // ctor + super(); } get maskedCode(): string { @@ -26,6 +34,7 @@ export class CardView implements View { return this.number != null ? '•'.repeat(this.number.length) : null; } + @linkedFieldOption(LinkedId.Brand) get brand(): string { return this._brand; } @@ -34,6 +43,7 @@ export class CardView implements View { this._subTitle = null; } + @linkedFieldOption(LinkedId.Number) get number(): string { return this._number; } diff --git a/common/src/models/view/cipherView.ts b/common/src/models/view/cipherView.ts index 9a2208c0a5..e4e5c3f195 100644 --- a/common/src/models/view/cipherView.ts +++ b/common/src/models/view/cipherView.ts @@ -1,5 +1,6 @@ import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CipherType } from '../../enums/cipherType'; +import { LinkedIdType } from '../../enums/linkedIdType'; import { Cipher } from '../domain/cipher'; @@ -7,6 +8,7 @@ import { AttachmentView } from './attachmentView'; import { CardView } from './cardView'; import { FieldView } from './fieldView'; import { IdentityView } from './identityView'; +import { ItemView } from './itemView'; import { LoginView } from './loginView'; import { PasswordHistoryView } from './passwordHistoryView'; import { SecureNoteView } from './secureNoteView'; @@ -57,16 +59,16 @@ export class CipherView implements View { this.reprompt = c.reprompt ?? CipherRepromptType.None; } - get subTitle(): string { + private get item() { switch (this.type) { case CipherType.Login: - return this.login.subTitle; + return this.login; case CipherType.SecureNote: - return this.secureNote.subTitle; + return this.secureNote; case CipherType.Card: - return this.card.subTitle; + return this.card; case CipherType.Identity: - return this.identity.subTitle; + return this.identity; default: break; } @@ -74,6 +76,10 @@ export class CipherView implements View { return null; } + get subTitle(): string { + return this.item.subTitle; + } + get hasPasswordHistory(): boolean { return this.passwordHistory && this.passwordHistory.length > 0; } @@ -109,4 +115,22 @@ export class CipherView implements View { get isDeleted(): boolean { return this.deletedDate != null; } + + get linkedFieldOptions() { + return this.item.linkedFieldOptions; + } + + linkedFieldValue(id: LinkedIdType) { + const linkedFieldOption = this.linkedFieldOptions?.get(id); + if (linkedFieldOption == null) { + return null; + } + + const item = this.item; + return this.item[linkedFieldOption.propertyKey as keyof typeof item]; + } + + linkedFieldI18nKey(id: LinkedIdType): string { + return this.linkedFieldOptions.get(id)?.i18nKey; + } } diff --git a/common/src/models/view/fieldView.ts b/common/src/models/view/fieldView.ts index 66c36744ac..3202d4b173 100644 --- a/common/src/models/view/fieldView.ts +++ b/common/src/models/view/fieldView.ts @@ -1,4 +1,5 @@ import { FieldType } from '../../enums/fieldType'; +import { LinkedIdType } from '../../enums/linkedIdType'; import { View } from './view'; @@ -10,6 +11,7 @@ export class FieldView implements View { type: FieldType = null; newField: boolean = false; // Marks if the field is new and hasn't been saved showValue: boolean = false; + linkedId: LinkedIdType = null; constructor(f?: Field) { if (!f) { @@ -17,6 +19,7 @@ export class FieldView implements View { } this.type = f.type; + this.linkedId = f.linkedId; } get maskedValue(): string { diff --git a/common/src/models/view/identityView.ts b/common/src/models/view/identityView.ts index 487a59bf1a..844a8acdb9 100644 --- a/common/src/models/view/identityView.ts +++ b/common/src/models/view/identityView.ts @@ -1,25 +1,45 @@ -import { View } from './view'; +import { ItemView } from './itemView'; import { Identity } from '../domain/identity'; import { Utils } from '../../misc/utils'; -export class IdentityView implements View { +import { IdentityLinkedId as LinkedId } from '../../enums/linkedIdType'; + +import { linkedFieldOption } from '../../misc/linkedFieldOption.decorator'; + +export class IdentityView extends ItemView { + @linkedFieldOption(LinkedId.Title) title: string = null; + @linkedFieldOption(LinkedId.MiddleName) middleName: string = null; + @linkedFieldOption(LinkedId.Address1) address1: string = null; + @linkedFieldOption(LinkedId.Address2) address2: string = null; + @linkedFieldOption(LinkedId.Address3) address3: string = null; + @linkedFieldOption(LinkedId.City, 'cityTown') city: string = null; + @linkedFieldOption(LinkedId.State, 'stateProvince') state: string = null; + @linkedFieldOption(LinkedId.PostalCode, 'zipPostalCode') postalCode: string = null; + @linkedFieldOption(LinkedId.Country) country: string = null; + @linkedFieldOption(LinkedId.Company) company: string = null; + @linkedFieldOption(LinkedId.Email) email: string = null; + @linkedFieldOption(LinkedId.Phone) phone: string = null; + @linkedFieldOption(LinkedId.Ssn) ssn: string = null; + @linkedFieldOption(LinkedId.Username) username: string = null; + @linkedFieldOption(LinkedId.PassportNumber) passportNumber: string = null; + @linkedFieldOption(LinkedId.LicenseNumber) licenseNumber: string = null; // tslint:disable @@ -29,9 +49,10 @@ export class IdentityView implements View { // tslint:enable constructor(i?: Identity) { - // ctor + super(); } + @linkedFieldOption(LinkedId.FirstName) get firstName(): string { return this._firstName; } @@ -40,6 +61,7 @@ export class IdentityView implements View { this._subTitle = null; } + @linkedFieldOption(LinkedId.LastName) get lastName(): string { return this._lastName; } @@ -65,6 +87,7 @@ export class IdentityView implements View { return this._subTitle; } + @linkedFieldOption(LinkedId.FullName) get fullName(): string { if (this.title != null || this.firstName != null || this.middleName != null || this.lastName != null) { let name = ''; diff --git a/common/src/models/view/itemView.ts b/common/src/models/view/itemView.ts new file mode 100644 index 0000000000..aaeac47351 --- /dev/null +++ b/common/src/models/view/itemView.ts @@ -0,0 +1,8 @@ +import { View } from './view'; + +import { LinkedMetadata } from '../../misc/linkedFieldOption.decorator'; + +export abstract class ItemView implements View { + linkedFieldOptions: Map; + abstract get subTitle(): string; +} diff --git a/common/src/models/view/loginView.ts b/common/src/models/view/loginView.ts index 7db2431004..3bd1ea5825 100644 --- a/common/src/models/view/loginView.ts +++ b/common/src/models/view/loginView.ts @@ -1,18 +1,27 @@ +import { ItemView } from './itemView'; import { LoginUriView } from './loginUriView'; -import { View } from './view'; import { Utils } from '../../misc/utils'; + import { Login } from '../domain/login'; -export class LoginView implements View { +import { LoginLinkedId as LinkedId } from '../../enums/linkedIdType'; + +import { linkedFieldOption } from '../../misc/linkedFieldOption.decorator'; + +export class LoginView extends ItemView { + @linkedFieldOption(LinkedId.Username) username: string = null; + @linkedFieldOption(LinkedId.Password) password: string = null; + passwordRevisionDate?: Date = null; totp: string = null; uris: LoginUriView[] = null; autofillOnPageLoad: boolean = null; constructor(l?: Login) { + super(); if (!l) { return; } diff --git a/common/src/models/view/secureNoteView.ts b/common/src/models/view/secureNoteView.ts index 6bd4cde70c..2d20902543 100644 --- a/common/src/models/view/secureNoteView.ts +++ b/common/src/models/view/secureNoteView.ts @@ -1,13 +1,14 @@ import { SecureNoteType } from '../../enums/secureNoteType'; -import { View } from './view'; +import { ItemView } from './itemView'; import { SecureNote } from '../domain/secureNote'; -export class SecureNoteView implements View { +export class SecureNoteView extends ItemView { type: SecureNoteType = null; constructor(n?: SecureNote) { + super(); if (!n) { return; } diff --git a/common/src/services/cipher.service.ts b/common/src/services/cipher.service.ts index bd6ab0184d..421f381ac6 100644 --- a/common/src/services/cipher.service.ts +++ b/common/src/services/cipher.service.ts @@ -226,6 +226,7 @@ export class CipherService implements CipherServiceAbstraction { async encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise { const field = new Field(); field.type = fieldModel.type; + field.linkedId = fieldModel.linkedId; // normalize boolean type field values if (fieldModel.type === FieldType.Boolean && fieldModel.value !== 'true') { fieldModel.value = 'false';