mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-27 12:36:14 +01:00
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:
parent
1bd968a023
commit
dbda39e10f
@ -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'));
|
||||
}
|
||||
}
|
||||
|
@ -2,4 +2,5 @@ export enum FieldType {
|
||||
Text = 0,
|
||||
Hidden = 1,
|
||||
Boolean = 2,
|
||||
Linked = 3,
|
||||
}
|
||||
|
40
common/src/enums/linkedIdType.ts
Normal file
40
common/src/enums/linkedIdType.ts
Normal 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,
|
||||
}
|
28
common/src/misc/linkedFieldOption.decorator.ts
Normal file
28
common/src/misc/linkedFieldOption.decorator.ts
Normal 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));
|
||||
};
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 = '';
|
||||
|
8
common/src/models/view/itemView.ts
Normal file
8
common/src/models/view/itemView.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user