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 <mgibson@bitwarden.com>

* Fix type casting

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Thomas Rittson 2021-11-03 08:03:37 +10:00 committed by GitHub
parent 1bd968a023
commit dbda39e10f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 206 additions and 17 deletions

View File

@ -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<string[]>) {
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'));
}
}

View File

@ -2,4 +2,5 @@ export enum FieldType {
Text = 0,
Hidden = 1,
Boolean = 2,
Linked = 3,
}

View File

@ -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,
}

View File

@ -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<LinkedIdType, LinkedMetadata>();
}
prototype.linkedFieldOptions.set(id, new LinkedMetadata(propertyKey, i18nKey));
};
}

View File

@ -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');
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
});
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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 = '';

View File

@ -0,0 +1,8 @@
import { View } from './view';
import { LinkedMetadata } from '../../misc/linkedFieldOption.decorator';
export abstract class ItemView implements View {
linkedFieldOptions: Map<number, LinkedMetadata>;
abstract get subTitle(): string;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -226,6 +226,7 @@ export class CipherService implements CipherServiceAbstraction {
async encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field> {
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';