1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 12:13:45 +00:00

Merge branch 'main' into dirt/pm-19322/accessibility

This commit is contained in:
Alex
2025-09-17 10:49:38 -04:00
39 changed files with 726 additions and 283 deletions

8
.github/CODEOWNERS vendored
View File

@@ -203,10 +203,10 @@ apps/web/src/locales/en/messages.json
.github/workflows/release-web.yml @bitwarden/dept-bre
## Docker files have shared ownership ##
**/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
## Overrides
# For the time being platform owns tsconfig and jest config

View File

@@ -1934,32 +1934,81 @@
"typeNote": {
"message": "Note"
},
"newItemHeader": {
"message": "New $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "Login"
}
}
"newItemHeaderLogin": {
"message": "New Login",
"description": "Header for new login item type"
},
"editItemHeader": {
"message": "Edit $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "Login"
}
}
"newItemHeaderCard": {
"message": "New Card",
"description": "Header for new card item type"
},
"viewItemHeader": {
"message": "View $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "Login"
}
}
"newItemHeaderIdentity": {
"message": "New Identity",
"description": "Header for new identity item type"
},
"newItemHeaderNote": {
"message": "New Note",
"description": "Header for new note item type"
},
"newItemHeaderSshKey": {
"message": "New SSH key",
"description": "Header for new SSH key item type"
},
"newItemHeaderTextSend": {
"message": "New Text Send",
"description": "Header for new text send"
},
"newItemHeaderFileSend": {
"message": "New File Send",
"description": "Header for new file send"
},
"editItemHeaderLogin": {
"message": "Edit Login",
"description": "Header for edit login item type"
},
"editItemHeaderCard": {
"message": "Edit Card",
"description": "Header for edit card item type"
},
"editItemHeaderIdentity": {
"message": "Edit Identity",
"description": "Header for edit identity item type"
},
"editItemHeaderNote": {
"message": "Edit Note",
"description": "Header for edit note item type"
},
"editItemHeaderSshKey": {
"message": "Edit SSH key",
"description": "Header for edit SSH key item type"
},
"editItemHeaderTextSend": {
"message": "Edit Text Send",
"description": "Header for edit text send"
},
"editItemHeaderFileSend": {
"message": "Edit File Send",
"description": "Header for edit file send"
},
"viewItemHeaderLogin": {
"message": "View Login",
"description": "Header for view login item type"
},
"viewItemHeaderCard": {
"message": "View Card",
"description": "Header for view card item type"
},
"viewItemHeaderIdentity": {
"message": "View Identity",
"description": "Header for view identity item type"
},
"viewItemHeaderNote": {
"message": "View Note",
"description": "Header for view note item type"
},
"viewItemHeaderSshKey": {
"message": "View SSH key",
"description": "Header for view SSH key item type"
},
"passwordHistory": {
"message": "Password history"
@@ -5092,15 +5141,9 @@
"itemLocation": {
"message": "Item Location"
},
"fileSend": {
"message": "File Send"
},
"fileSends": {
"message": "File Sends"
},
"textSend": {
"message": "Text Send"
},
"textSends": {
"message": "Text Sends"
},

View File

@@ -2513,6 +2513,7 @@ export default class AutofillService implements AutofillServiceInterface {
"label-tag",
"placeholder",
"label-left",
"label-right",
"label-top",
"label-aria",
"dataSetValues",
@@ -2609,7 +2610,7 @@ export default class AutofillService implements AutofillServiceInterface {
}
/**
* Updates a fill script to place the `cilck_on_opid`, `focus_on_opid`, and `fill_by_opid`
* Updates a fill script to place the `click_on_opid`, `focus_on_opid`, and `fill_by_opid`
* fill script actions associated with the provided field.
* @param {AutofillScript} fillScript
* @param {AutofillField} field

View File

@@ -221,7 +221,7 @@ describe("InlineMenuFieldQualificationService", () => {
expect(
inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails),
).toBe(false);
).toBe(true);
});
});
@@ -509,7 +509,7 @@ describe("InlineMenuFieldQualificationService", () => {
expect(
inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails),
).toBe(false);
).toBe(true);
});
it("is structured on a page with no password fields but has other types of fields in the form", () => {
@@ -568,7 +568,7 @@ describe("InlineMenuFieldQualificationService", () => {
).toBe(false);
});
it("contains a disabled autocomplete type when multiple password fields are on the page", () => {
it("will not exclude a field by autocomplete type when it is the only viewable password field on the page", () => {
const field = mock<AutofillField>({
type: "text",
autoCompleteType: "off",
@@ -599,7 +599,7 @@ describe("InlineMenuFieldQualificationService", () => {
expect(
inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails),
).toBe(false);
).toBe(true);
});
});
});

View File

@@ -37,7 +37,6 @@ export class InlineMenuFieldQualificationService
private newPasswordAutoCompleteValue = "new-password";
private autofillFieldKeywordsMap: AutofillKeywordsMap = new WeakMap();
private submitButtonKeywordsMap: SubmitButtonKeywordsMap = new WeakMap();
private autocompleteDisabledValues = new Set(["off", "false"]);
private accountCreationFieldKeywords = [
"register",
"registration",
@@ -419,10 +418,8 @@ export class InlineMenuFieldQualificationService
}
// If a single username field or less is present on the page, then we can assume that the
// provided field is for a login form. This will only be the case if the field does not
// explicitly have its autocomplete attribute set to "off" or "false".
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
// provided field is for a login form.
return true;
}
// If the field has a form parent and there are multiple visible password fields
@@ -442,9 +439,8 @@ export class InlineMenuFieldQualificationService
return true;
}
// If the field has a form parent and no username field exists and the field has an
// autocomplete attribute set to "off" or "false", this is not a password field
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
// If the field has a form parent and a username field exists this is a password field
return true;
}
/**
@@ -512,20 +508,12 @@ export class InlineMenuFieldQualificationService
}
// If the page does not contain any password fields, it might be part of a multistep login form.
// That will only be the case if the field does not explicitly have its autocomplete attribute
// set to "off" or "false".
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
return true;
}
// If the field is structured within a form, but no password fields are present in the form,
// we need to consider whether the field is part of a multistep login form.
if (passwordFieldsInPageDetails.length === 0) {
// If the field's autocomplete is set to a disabled value, we should assume that the field is
// not part of a login form.
if (this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues)) {
return false;
}
// If the form that contains a single field, we should assume that it is part
// of a multistep login form.
const fieldsWithinForm = pageDetails.fields.filter(
@@ -561,8 +549,7 @@ export class InlineMenuFieldQualificationService
}
// If no visible password fields are found, this field might be part of a multipart form.
// Check for an invalid autocompleteType to determine if the field is part of a login form.
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
return true;
}
/**

View File

@@ -188,14 +188,11 @@ export class SendAddEditComponent {
* @returns The header text.
*/
private getHeaderText(mode: SendFormMode, type: SendType) {
const headerKey =
mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case SendType.Text:
return this.i18nService.t(headerKey, this.i18nService.t("textSend"));
case SendType.File:
return this.i18nService.t(headerKey, this.i18nService.t("fileSend"));
}
const isEditMode = mode === "edit" || mode === "partial-edit";
const translation = {
[SendType.Text]: isEditMode ? "editItemHeaderTextSend" : "newItemHeaderTextSend",
[SendType.File]: isEditMode ? "editItemHeaderFileSend" : "newItemHeaderFileSend",
};
return this.i18nService.t(translation[type]);
}
}

View File

@@ -368,20 +368,15 @@ export class AddEditV2Component implements OnInit {
}
setHeader(mode: CipherFormMode, type: CipherType) {
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case CipherType.Login:
return this.i18nService.t(partOne, this.i18nService.t("typeLogin"));
case CipherType.Card:
return this.i18nService.t(partOne, this.i18nService.t("typeCard"));
case CipherType.Identity:
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity"));
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note"));
case CipherType.SshKey:
return this.i18nService.t(partOne, this.i18nService.t("typeSshKey"));
}
const isEditMode = mode === "edit" || mode === "partial-edit";
const translation = {
[CipherType.Login]: isEditMode ? "editItemHeaderLogin" : "newItemHeaderLogin",
[CipherType.Card]: isEditMode ? "editItemHeaderCard" : "newItemHeaderCard",
[CipherType.Identity]: isEditMode ? "editItemHeaderIdentity" : "newItemHeaderIdentity",
[CipherType.SecureNote]: isEditMode ? "editItemHeaderNote" : "newItemHeaderNote",
[CipherType.SshKey]: isEditMode ? "editItemHeaderSshKey" : "newItemHeaderSshKey",
};
return this.i18nService.t(translation[type]);
}
delete = async () => {

View File

@@ -172,28 +172,28 @@ describe("ViewV2Component", () => {
params$.next({ cipherId: mockCipher.id });
flush(); // Resolve all promises
expect(component.headerText).toEqual("viewItemHeader typeLogin");
expect(component.headerText).toEqual("viewItemHeaderLogin");
// Set header text for a card
mockCipher.type = CipherType.Card;
params$.next({ cipherId: mockCipher.id });
flush(); // Resolve all promises
expect(component.headerText).toEqual("viewItemHeader typeCard");
expect(component.headerText).toEqual("viewItemHeaderCard");
// Set header text for an identity
mockCipher.type = CipherType.Identity;
params$.next({ cipherId: mockCipher.id });
flush(); // Resolve all promises
expect(component.headerText).toEqual("viewItemHeader typeIdentity");
expect(component.headerText).toEqual("viewItemHeaderIdentity");
// Set header text for a secure note
mockCipher.type = CipherType.SecureNote;
params$.next({ cipherId: mockCipher.id });
flush(); // Resolve all promises
expect(component.headerText).toEqual("viewItemHeader note");
expect(component.headerText).toEqual("viewItemHeaderNote");
}));
it("sends viewed event", fakeAsync(() => {

View File

@@ -194,18 +194,14 @@ export class ViewV2Component {
}
setHeader(type: CipherType) {
switch (type) {
case CipherType.Login:
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeLogin"));
case CipherType.Card:
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeCard"));
case CipherType.Identity:
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeIdentity"));
case CipherType.SecureNote:
return this.i18nService.t("viewItemHeader", this.i18nService.t("note"));
case CipherType.SshKey:
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeSshkey"));
}
const translation = {
[CipherType.Login]: "viewItemHeaderLogin",
[CipherType.Card]: "viewItemHeaderCard",
[CipherType.Identity]: "viewItemHeaderIdentity",
[CipherType.SecureNote]: "viewItemHeaderNote",
[CipherType.SshKey]: "viewItemHeaderSshKey",
};
return this.i18nService.t(translation[type]);
}
async getCipherData(id: string, userId: UserId) {

View File

@@ -68,7 +68,7 @@
"big-integer": "1.6.52",
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "11.1.0",
"commander": "14.0.0",
"core-js": "3.45.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",

View File

@@ -228,8 +228,8 @@ export declare namespace chromium_importer {
login?: Login
failure?: LoginImportFailure
}
export function getInstalledBrowsers(): Promise<Array<string>>
export function getAvailableProfiles(browser: string): Promise<Array<ProfileInfo>>
export function getInstalledBrowsers(): Array<string>
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
}
export declare namespace autotype {

View File

@@ -926,17 +926,22 @@ export class VaultV2Component<C extends CipherViewLike>
}
} else if (this.activeFilter.selectedOrganizationId) {
this.addOrganizationId = this.activeFilter.selectedOrganizationId;
} else {
// clear out organizationId when the user switches to a personal vault filter
this.addOrganizationId = null;
}
if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) {
this.folderId = this.activeFilter.selectedFolderId;
}
if (this.addOrganizationId && this.config) {
this.config.initialValues = {
...this.config.initialValues,
organizationId: this.addOrganizationId as OrganizationId,
};
if (this.config == null) {
return;
}
this.config.initialValues = {
...this.config.initialValues,
organizationId: this.addOrganizationId as OrganizationId,
};
}
private async canNavigateAway(action: string, cipher?: CipherView) {

View File

@@ -66,6 +66,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import {
CipherViewLike,
CipherViewLikeUtils,
@@ -288,6 +289,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy {
private billingNotificationService: BillingNotificationService,
private organizationWarningsService: OrganizationWarningsService,
private collectionService: CollectionService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.userId$ = this.accountService.activeAccount$.pipe(getUserId);
this.filter$ = this.routedVaultFilterService.filter$;
@@ -357,9 +359,10 @@ export class vNextVaultComponent implements OnInit, OnDestroy {
this.allCiphers$ = combineLatest([
this.organization$,
this.userId$,
this.restrictedItemTypesService.restricted$,
this.refreshingSubject$,
]).pipe(
switchMap(async ([organization, userId]) => {
switchMap(async ([organization, userId, restricted]) => {
// If user swaps organization reset the addAccessToggle
if (!this.showAddAccessToggle || organization) {
this.addAccessToggle(0);
@@ -381,6 +384,11 @@ export class vNextVaultComponent implements OnInit, OnDestroy {
ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id);
}
// Filter out restricted ciphers before indexing
ciphers = ciphers.filter(
(cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted),
);
await this.searchService.indexCiphers(userId, ciphers, organization.id);
return ciphers;
}),

View File

@@ -134,7 +134,7 @@ describe("EmergencyViewDialogComponent", () => {
component["updateTitle"]();
expect(component["title"]).toBe("viewItemType typelogin");
expect(component["title"]).toBe("viewItemHeaderLogin");
});
it("sets card title", () => {
@@ -142,7 +142,7 @@ describe("EmergencyViewDialogComponent", () => {
component["updateTitle"]();
expect(component["title"]).toBe("viewItemType typecard");
expect(component["title"]).toBe("viewItemHeaderCard");
});
it("sets identity title", () => {
@@ -150,7 +150,7 @@ describe("EmergencyViewDialogComponent", () => {
component["updateTitle"]();
expect(component["title"]).toBe("viewItemType typeidentity");
expect(component["title"]).toBe("viewItemHeaderIdentity");
});
it("sets note title", () => {
@@ -158,7 +158,7 @@ describe("EmergencyViewDialogComponent", () => {
component["updateTitle"]();
expect(component["title"]).toBe("viewItemType note");
expect(component["title"]).toBe("viewItemHeaderNote");
});
});
});

View File

@@ -73,22 +73,20 @@ export class EmergencyViewDialogComponent {
};
private updateTitle() {
const partOne = "viewItemType";
const type = this.cipher.type;
switch (type) {
case CipherType.Login:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
this.title = this.i18nService.t("viewItemHeaderLogin");
break;
case CipherType.Card:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
this.title = this.i18nService.t("viewItemHeaderCard");
break;
case CipherType.Identity:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
this.title = this.i18nService.t("viewItemHeaderIdentity");
break;
case CipherType.SecureNote:
this.title = this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
this.title = this.i18nService.t("viewItemHeaderNote");
break;
}
}

View File

@@ -521,36 +521,39 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return await this.cipherService.decrypt(config.originalCipher, activeUserId);
}
private updateTitle() {
let partOne: string;
private updateTitle(): void {
const mode = this.formConfig.mode || this.params.mode;
const type = this.cipher?.type ?? this.formConfig.cipherType;
const translation: { [key: string]: { [key: number]: string } } = {
view: {
[CipherType.Login]: "viewItemHeaderLogin",
[CipherType.Card]: "viewItemHeaderCard",
[CipherType.Identity]: "viewItemHeaderIdentity",
[CipherType.SecureNote]: "viewItemHeaderNote",
[CipherType.SshKey]: "viewItemHeaderSshKey",
},
new: {
[CipherType.Login]: "newItemHeaderLogin",
[CipherType.Card]: "newItemHeaderCard",
[CipherType.Identity]: "newItemHeaderIdentity",
[CipherType.SecureNote]: "newItemHeaderNote",
[CipherType.SshKey]: "newItemHeaderSshKey",
},
edit: {
[CipherType.Login]: "editItemHeaderLogin",
[CipherType.Card]: "editItemHeaderCard",
[CipherType.Identity]: "editItemHeaderIdentity",
[CipherType.SecureNote]: "editItemHeaderNote",
[CipherType.SshKey]: "editItemHeaderSshKey",
},
};
if (this.params.mode === "view") {
partOne = "viewItemType";
} else if (this.formConfig.mode === "edit" || this.formConfig.mode === "partial-edit") {
partOne = "editItemHeader";
} else {
partOne = "newItemHeader";
}
const effectiveMode =
mode === "partial-edit" || mode === "edit" ? "edit" : translation[mode] ? mode : "new";
const type = this.cipher?.type ?? this.formConfig.cipherType ?? CipherType.Login;
const fullTranslation = translation[effectiveMode][type];
switch (type) {
case CipherType.Login:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin"));
break;
case CipherType.Card:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard"));
break;
case CipherType.Identity:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity"));
break;
case CipherType.SecureNote:
this.title = this.i18nService.t(partOne, this.i18nService.t("note"));
break;
case CipherType.SshKey:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeSshKey"));
break;
}
this.title = this.i18nService.t(fullTranslation);
}
/**

View File

@@ -138,19 +138,15 @@ export class AddEditComponentV2 implements OnInit {
* @returns The header text.
*/
setHeader(mode: CipherFormMode, type: CipherType) {
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case CipherType.Login:
return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
case CipherType.Card:
return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
case CipherType.Identity:
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
case CipherType.SshKey:
return this.i18nService.t(partOne, this.i18nService.t("typeSshKey").toLowerCase());
}
const isEditMode = mode === "edit" || mode === "partial-edit";
const translation = {
[CipherType.Login]: isEditMode ? "editItemHeaderLogin" : "newItemHeaderLogin",
[CipherType.Card]: isEditMode ? "editItemHeaderCard" : "newItemHeaderCard",
[CipherType.Identity]: isEditMode ? "editItemHeaderIdentity" : "newItemHeaderIdentity",
[CipherType.SecureNote]: isEditMode ? "editItemHeaderNote" : "newItemHeaderNote",
[CipherType.SshKey]: isEditMode ? "editItemHeaderSshKey" : "newItemHeaderSshKey",
};
return this.i18nService.t(translation[type]);
}
/**

View File

@@ -194,15 +194,15 @@ export class ViewComponent implements OnInit {
switch (this.cipher.type) {
case CipherType.Login:
return this.i18nService.t("viewItemType", this.i18nService.t("typeLogin").toLowerCase());
return this.i18nService.t("viewItemHeaderLogin");
case CipherType.SecureNote:
return this.i18nService.t("viewItemType", this.i18nService.t("note").toLowerCase());
return this.i18nService.t("viewItemHeaderCard");
case CipherType.Card:
return this.i18nService.t("viewItemType", this.i18nService.t("typeCard").toLowerCase());
return this.i18nService.t("viewItemHeaderIdentity");
case CipherType.Identity:
return this.i18nService.t("viewItemType", this.i18nService.t("typeIdentity").toLowerCase());
return this.i18nService.t("viewItemHeaderNote");
case CipherType.SshKey:
return this.i18nService.t("viewItemType", this.i18nService.t("typeSshKey").toLowerCase());
return this.i18nService.t("viewItemHeaderSshKey");
default:
return null;
}

View File

@@ -734,32 +734,81 @@
"viewItem": {
"message": "View item"
},
"newItemHeader": {
"message": "New $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "login"
}
}
"newItemHeaderLogin": {
"message": "New Login",
"description": "Header for new login item type"
},
"editItemHeader": {
"message": "Edit $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "login"
}
}
"newItemHeaderCard": {
"message": "New Card",
"description": "Header for new card item type"
},
"viewItemType": {
"message": "View $ITEMTYPE$",
"placeholders": {
"itemtype": {
"content": "$1",
"example": "login"
}
}
"newItemHeaderIdentity": {
"message": "New Identity",
"description": "Header for new identity item type"
},
"newItemHeaderNote": {
"message": "New Note",
"description": "Header for new note item type"
},
"newItemHeaderSshKey": {
"message": "New SSH key",
"description": "Header for new SSH key item type"
},
"newItemHeaderTextSend": {
"message": "New Text Send",
"description": "Header for new text send"
},
"newItemHeaderFileSend": {
"message": "New File Send",
"description": "Header for new file send"
},
"editItemHeaderLogin": {
"message": "Edit Login",
"description": "Header for edit login item type"
},
"editItemHeaderCard": {
"message": "Edit Card",
"description": "Header for edit card item type"
},
"editItemHeaderIdentity": {
"message": "Edit Identity",
"description": "Header for edit identity item type"
},
"editItemHeaderNote": {
"message": "Edit Note",
"description": "Header for edit note item type"
},
"editItemHeaderSshKey": {
"message": "Edit SSH key",
"description": "Header for edit SSH key item type"
},
"editItemHeaderTextSend": {
"message": "Edit Text Send",
"description": "Header for edit text send"
},
"editItemHeaderFileSend": {
"message": "Edit File Send",
"description": "Header for edit file send"
},
"viewItemHeaderLogin": {
"message": "View Login",
"description": "Header for view login item type"
},
"viewItemHeaderCard": {
"message": "View Card",
"description": "Header for view card item type"
},
"viewItemHeaderIdentity": {
"message": "View Identity",
"description": "Header for view identity item type"
},
"viewItemHeaderNote": {
"message": "View Note",
"description": "Header for view note item type"
},
"viewItemHeaderSshKey": {
"message": "View SSH key",
"description": "Header for view SSH key item type"
},
"new": {
"message": "New",
@@ -9670,6 +9719,9 @@
"failedToSaveIntegration": {
"message": "Failed to save integration. Please try again later."
},
"failedToDeleteIntegration": {
"message": "Failed to delete integration. Please try again later."
},
"deviceIdMissing": {
"message": "Device ID is missing"
},
@@ -10214,15 +10266,9 @@
"learnMoreAboutApi": {
"message": "Learn more about Bitwarden's API"
},
"fileSend": {
"message": "File Send"
},
"fileSends": {
"message": "File Sends"
},
"textSend": {
"message": "Text Send"
},
"textSends": {
"message": "Text Sends"
},

View File

@@ -20,7 +20,6 @@ export type Integration = {
*/
newBadgeExpiration?: string;
description?: string;
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
template?: string;

View File

@@ -156,6 +156,34 @@ export class HecOrganizationIntegrationService {
}
}
async deleteHec(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
) {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// delete the integration
await this.integrationApiService.deleteOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
);
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next(updatedIntegrations);
}
/**
* Gets a OrganizationIntegration for an OrganizationIntegrationId
* @param integrationId id of the integration

View File

@@ -13,12 +13,13 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { openHecConnectDialog } from "../integration-dialog";
import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationCardComponent } from "./integration-card.component";
jest.mock("../integration-dialog", () => ({
openHecConnectDialog: jest.fn(),
HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" },
}));
describe("IntegrationCardComponent", () => {
@@ -272,7 +273,7 @@ describe("IntegrationCardComponent", () => {
it("should call updateHec if isUpdateAvailable is true", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
success: HecConnectDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -304,7 +305,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
success: HecConnectDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -327,10 +328,66 @@ describe("IntegrationCardComponent", () => {
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
});
it("should show toast on error", async () => {
it("should call deleteHec when a delete is requested", async () => {
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
mockIntegrationService.deleteHec.mockResolvedValue(undefined);
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
);
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
});
it("should not call deleteHec if no existing configuration", async () => {
component.integrationSettings = {
organizationIntegration: null,
name: OrganizationIntegrationServiceType.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
mockIntegrationService.deleteHec.mockResolvedValue(undefined);
await component.setupConnection();
expect(mockIntegrationService.deleteHec).not.toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
);
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
});
it("should show toast on error while saving", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -349,5 +406,28 @@ describe("IntegrationCardComponent", () => {
message: mockI18nService.t("failedToSaveIntegration"),
});
});
it("should show toast on error while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.deleteHec.mockRejectedValue(new Error("fail"));
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("failedToDeleteIntegration"),
});
});
});
});

View File

@@ -21,7 +21,11 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { openHecConnectDialog } from "../integration-dialog/index";
import {
HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog,
} from "../integration-dialog/index";
@Component({
selector: "app-integration-card",
@@ -142,32 +146,20 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
try {
if (this.isUpdateAvailable) {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (result.success === HecConnectDialogResultStatus.Delete) {
await this.deleteHec();
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
await this.hecOrganizationIntegrationService.updateHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
} else {
await this.hecOrganizationIntegrationService.saveHec(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
try {
if (result.success === HecConnectDialogResultStatus.Edited) {
await this.saveHec(result);
}
} catch {
this.toastService.showToast({
@@ -175,7 +167,55 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
title: "",
message: this.i18nService.t("failedToSaveIntegration"),
});
return;
}
}
async saveHec(result: HecConnectDialogResult) {
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
// update existing integration and configuration
await this.hecOrganizationIntegrationService.updateHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
} else {
// create new integration and configuration
await this.hecOrganizationIntegrationService.saveHec(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
}
}
async deleteHec() {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
await this.hecOrganizationIntegrationService.deleteHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
}
}

View File

@@ -37,6 +37,19 @@
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
@if (canDelete) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
label="'delete' | i18n"
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
></button>
</div>
}
</ng-container>
</bit-dialog>
</form>

View File

@@ -14,6 +14,7 @@ import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
@@ -65,7 +66,6 @@ describe("ConnectDialogHecComponent", () => {
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
isConnected: false,
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
@@ -155,8 +155,7 @@ describe("ConnectDialogHecComponent", () => {
bearerToken: "token",
index: "1",
service: "Test Service",
success: true,
error: null,
success: HecConnectDialogResultStatus.Edited,
});
});
});

View File

@@ -17,10 +17,17 @@ export interface HecConnectDialogResult {
bearerToken: string;
index: string;
service: string;
success: boolean;
error: string | null;
success: HecConnectDialogResultStatusType | null;
}
export const HecConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type HecConnectDialogResultStatusType =
(typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus];
@Component({
templateUrl: "./connect-dialog-hec.component.html",
imports: [SharedModule],
@@ -40,6 +47,7 @@ export class ConnectHecDialogComponent implements OnInit {
@Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HecConnectDialogResult>,
private dialogService: DialogService,
) {}
ngOnInit(): void {
@@ -62,23 +70,51 @@ export class ConnectHecDialogComponent implements OnInit {
return !!this.hecConfig;
}
submit = async (): Promise<void> => {
const formJson = this.formGroup.getRawValue();
get canDelete(): boolean {
return !!this.hecConfig;
}
const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings,
url: formJson.url || "",
bearerToken: formJson.bearerToken || "",
index: formJson.index || "",
service: formJson.service || "",
success: true,
error: null,
};
submit = async (): Promise<void> => {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited);
this.dialogRef.close(result);
return;
};
delete = async (): Promise<void> => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: "deleteItemConfirmation",
},
type: "warning",
});
if (confirmed) {
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHecConnectDialogResult(
status: HecConnectDialogResultStatusType,
): HecConnectDialogResult {
const formJson = this.formGroup.getRawValue();
return {
integrationSettings: this.connectInfo.settings,
url: formJson.url || "",
bearerToken: formJson.bearerToken || "",
index: formJson.index || "",
service: formJson.service || "",
success: status,
};
}
}
export function openHecConnectDialog(

View File

@@ -253,7 +253,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT,
description: "crowdstrikeEventIntegrationDesc",
isConnected: false,
canSetupConnection: true,
};
@@ -265,6 +264,11 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.hecOrganizationIntegrationService.integrations$
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// reset all integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
i.organizationIntegration = null;
});
integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
if (item) {

View File

@@ -1,5 +1,11 @@
import { NgModule } from "@angular/core";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { safeProvider } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
@@ -14,6 +20,23 @@ import { IntegrationsComponent } from "./integrations.component";
IntegrationCardComponent,
IntegrationGridComponent,
],
providers: [
safeProvider({
provide: HecOrganizationIntegrationService,
useClass: HecOrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({
provide: OrganizationIntegrationApiService,
useClass: OrganizationIntegrationApiService,
deps: [ApiService],
}),
safeProvider({
provide: OrganizationIntegrationConfigurationApiService,
useClass: OrganizationIntegrationConfigurationApiService,
deps: [ApiService],
}),
],
declarations: [IntegrationsComponent],
})
export class IntegrationsModule {}

View File

@@ -0,0 +1,110 @@
import { EventType } from "./event-type.enum";
export const EventCategory = {
UserEvents: "userEvents",
ItemEvents: "itemEvents",
CollectionEvents: "collectionEvents",
GroupEvents: "groupEvents",
OrganizationMemberEvents: "organizationMemberEvents",
OrganizationEvents: "organizationEvents",
ProviderEvents: "providerEvents",
} as const;
export type EventCategory = (typeof EventCategory)[keyof typeof EventCategory];
export const EventCategoryEventTypes: Record<EventCategory, EventType[]> = {
[EventCategory.UserEvents]: [
EventType.User_LoggedIn,
EventType.User_ChangedPassword,
EventType.User_Updated2fa,
EventType.User_Disabled2fa,
EventType.User_Recovered2fa,
EventType.User_FailedLogIn,
EventType.User_FailedLogIn2fa,
EventType.User_ClientExportedVault,
EventType.User_UpdatedTempPassword,
EventType.User_MigratedKeyToKeyConnector,
EventType.User_RequestedDeviceApproval,
EventType.User_TdeOffboardingPasswordSet,
],
[EventCategory.ItemEvents]: [
EventType.Cipher_Created,
EventType.Cipher_Updated,
EventType.Cipher_Deleted,
EventType.Cipher_AttachmentCreated,
EventType.Cipher_AttachmentDeleted,
EventType.Cipher_Shared,
EventType.Cipher_UpdatedCollections,
EventType.Cipher_ClientViewed,
EventType.Cipher_ClientToggledPasswordVisible,
EventType.Cipher_ClientToggledHiddenFieldVisible,
EventType.Cipher_ClientToggledCardCodeVisible,
EventType.Cipher_ClientCopiedPassword,
EventType.Cipher_ClientCopiedHiddenField,
EventType.Cipher_ClientCopiedCardCode,
EventType.Cipher_ClientAutofilled,
EventType.Cipher_SoftDeleted,
EventType.Cipher_Restored,
EventType.Cipher_ClientToggledCardNumberVisible,
EventType.Cipher_ClientToggledTOTPSeedVisible,
],
[EventCategory.CollectionEvents]: [
EventType.Collection_Created,
EventType.Collection_Updated,
EventType.Collection_Deleted,
],
[EventCategory.GroupEvents]: [
EventType.Group_Created,
EventType.Group_Updated,
EventType.Group_Deleted,
],
[EventCategory.OrganizationMemberEvents]: [
EventType.OrganizationUser_Invited,
EventType.OrganizationUser_Confirmed,
EventType.OrganizationUser_Updated,
EventType.OrganizationUser_Removed,
EventType.OrganizationUser_UpdatedGroups,
EventType.OrganizationUser_UnlinkedSso,
EventType.OrganizationUser_ResetPassword_Enroll,
EventType.OrganizationUser_ResetPassword_Withdraw,
EventType.OrganizationUser_AdminResetPassword,
EventType.OrganizationUser_ResetSsoLink,
EventType.OrganizationUser_FirstSsoLogin,
EventType.OrganizationUser_Revoked,
EventType.OrganizationUser_Restored,
EventType.OrganizationUser_ApprovedAuthRequest,
EventType.OrganizationUser_RejectedAuthRequest,
EventType.OrganizationUser_Deleted,
EventType.OrganizationUser_Left,
],
[EventCategory.OrganizationEvents]: [
EventType.Organization_Updated,
EventType.Organization_PurgedVault,
EventType.Organization_ClientExportedVault,
EventType.Organization_VaultAccessed,
EventType.Organization_EnabledSso,
EventType.Organization_DisabledSso,
EventType.Organization_EnabledKeyConnector,
EventType.Organization_DisabledKeyConnector,
EventType.Organization_SponsorshipsSynced,
EventType.Organization_CollectionManagementUpdated,
EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled,
EventType.Organization_CollectionManagement_LimitCollectionCreationDisabled,
EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled,
EventType.Organization_CollectionManagement_LimitCollectionDeletionDisabled,
EventType.Organization_CollectionManagement_LimitItemDeletionEnabled,
EventType.Organization_CollectionManagement_LimitItemDeletionDisabled,
EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled,
EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled,
],
[EventCategory.ProviderEvents]: [
EventType.ProviderUser_Invited,
EventType.ProviderUser_Confirmed,
EventType.ProviderUser_Updated,
EventType.ProviderUser_Removed,
EventType.ProviderOrganization_Created,
EventType.ProviderOrganization_Added,
EventType.ProviderOrganization_Removed,
EventType.ProviderOrganization_VaultAccessed,
],
};

View File

@@ -41,6 +41,7 @@ export enum FeatureFlag {
/* DIRT */
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
@@ -84,6 +85,7 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
/* Vault */
[FeatureFlag.CipherKeyEncryption]: FALSE,

View File

@@ -14,7 +14,12 @@
</a>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
<div *ngIf="!hideIcon()" class="tw-size-20 sm:tw-size-24 tw-mx-auto tw-content-center">
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
*ngIf="!hideIcon()"
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="icon()"></bit-icon>
</div>

View File

@@ -4,6 +4,11 @@ import { Meta } from "@storybook/addon-docs";
<style>
{`
/* undo global app style for ol */
ol {
list-style: revert;
}
.subheading {
--mediumdark: '#999999';
font-weight: 900;
@@ -171,6 +176,11 @@ what would be helpful to you if you were consuming this component for the first
- You'll need to mock any services required for the component (team-owned components often have
more of this mocking to do)
### Running Storybook locally
To view your changes locally, cd into `/clients`, run `npm ci` if you haven't already, and run
`npm run storybook`.
---
<div className="subheading">References</div>

View File

@@ -4,9 +4,27 @@ import { Meta } from "@storybook/addon-docs";
# Migrating to the Component Library
## Background
Bitwarden is in the process of migrating client code to fully use the Component Library.
Previously, the clients used a mix of multiple UI frameworks: Bootstrap, custom "box" styles, and
this component library, which is built on top of Tailwind.
Bootstrap is now removed from the clients repo in favor of using Tailwind Preflight for global reset
styles.
Tailwind is used in client code where layout-level html is written in conjunction with Component
Library components.
Some custom css still lingers in all the clients and is planned to be removed. Do not write more
custom css.
## Performing a migration
You have been tasked with migrating a component to use the CL. What does that entail?
## Getting Started
### Getting Started
Before progressing here, please ensure that...
@@ -15,13 +33,6 @@ Before progressing here, please ensure that...
- You are familiar with [Angular reactive forms](https://angular.io/guide/reactive-forms).
- You are familiar with [Tailwind](https://tailwindcss.com/docs/utility-first).
## Background
The design of Bitwarden is in flux. At the time of writing, the frontend codebase uses a mix of
multiple UI frameworks: Bootstrap, custom "box" styles, and this component library, which is built
on top of Tailwind. In short, the "CL migration" is a move to only use the CL and remove everything
else.
This is very important work. Centralizing around a shared design system will:
- improve user experience by utilizing consistent patterns
@@ -29,11 +40,11 @@ This is very important work. Centralizing around a shared design system will:
- improve dev & design velocity by having a central location to make UI/UX changes that impact the
entire project
## Success Criteria
### Success Criteria
Follow these steps to fully migrate a component.
### Use Storybook
#### Use Storybook
Don't recreate the wheel.
@@ -42,7 +53,7 @@ usecase. Don't waste effort styling a button or building a popover menu from scr
have those. If a component isn't flexible enough or doesn't exist for your usecase, contact the
Component Library team.
### Use Tailwind
#### Use Tailwind
Only use Tailwind for styling. No Bootstrap or other custom CSS is allowed.
@@ -72,7 +83,7 @@ without this prefix, it probably shouldn't be there.
```
</div>
### Use Reactive Forms
#### Use Reactive Forms
The CL has form components that integrate with Angular's reactive forms: `bit-form-field`,
`bitSubmit`, `bit-form-control`, etc. All forms should be migrated from template-drive forms to
@@ -97,7 +108,7 @@ reactive forms to make use of these components. Review the
```
</div>
### Dialogs
#### Dialogs
Legacy Bootstrap modals use the `ModalService`. These should be converted to use the `DialogService`
and it's [related CL components](?path=/docs/component-library-dialogs--docs). Components that are
@@ -161,11 +172,11 @@ fully migrated should have no reference to the `ModalService`.
<div class="tw-bg-success-600/10 tw-p-4">`FooDialogComponent.open(this.dialogService);`</div>
## Examples
### Examples
The following examples come from accross the Bitwarden codebase.
### 1.) AboutComponent
#### 1.) AboutComponent
Codeowner: Platform
@@ -176,7 +187,7 @@ This migration updates a `ModalService` component to the `DialogService`.
**Note:** Most of the internal markup of this component was unchanged, aside from the removal of
defunct Bootstrap classes.
### 2.) Auth
#### 2.) Auth
Codeowner: Auth
@@ -190,7 +201,7 @@ This PR also does some general refactoring, the main relevant change can be seen
Updates a dialog, similar to example 1, but also adds CL form components and Angular Reactive Forms.
### 3.) AC
#### 3.) AC
Codeowner: Admin Console
@@ -198,7 +209,7 @@ https://github.com/bitwarden/clients/pull/5417
Migrates dialog, form, buttons, and a table.
### 4.) Vault
#### 4.) Vault
Codeowner: Vault
@@ -209,6 +220,6 @@ through the use of inheritance. This PR updates the _web_ template of a cross-cl
use Tailwind and the CL, and updates the base component implementation to use reactive forms,
without updating the desktop or browser templates.
## Questions
### Questions
Please direct any development questions to the Component Library team. Thank you!

View File

@@ -136,5 +136,17 @@ describe("BitwardenPasswordProtectedImporter", () => {
jDoc.data = null;
expect((await importer.parse(JSON.stringify(jDoc))).success).toEqual(false);
});
it("returns invalidFilePassword errorMessage if decryptString throws", async () => {
encryptService.decryptString.mockImplementation(() => {
throw new Error("SDK error");
});
i18nService.t.mockReturnValue("invalidFilePassword");
const result = await importer.parse(JSON.stringify(jDoc));
expect(result.success).toBe(false);
expect(result.errorMessage).toBe("invalidFilePassword");
});
});
});

View File

@@ -90,14 +90,12 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
const encKeyValidation = new EncString(jdoc.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.encryptService.decryptString(
encKeyValidation,
this.key,
);
if (encKeyValidationDecrypt === null) {
try {
await this.encryptService.decryptString(encKeyValidation, this.key);
return true;
} catch {
return false;
}
return true;
}
private cannotParseFile(jdoc: BitwardenPasswordProtectedFileFormat): boolean {

View File

@@ -10,6 +10,7 @@
<button
type="button"
bitButton
appAutofocus
buttonType="primary"
class="tw-mb-3"
[disabled]="unlockingViaBiometrics || !biometricsAvailable"

View File

@@ -153,15 +153,12 @@ export class SendAddEditDialogComponent {
* @returns The header text.
*/
private getHeaderText(mode: SendFormMode, type: SendType) {
const headerKey =
mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case SendType.Text:
return this.i18nService.t(headerKey, this.i18nService.t("textSend"));
case SendType.File:
return this.i18nService.t(headerKey, this.i18nService.t("fileSend"));
}
const isEditMode = mode === "edit" || mode === "partial-edit";
const translation = {
[SendType.Text]: isEditMode ? "editItemHeaderTextSend" : "newItemHeaderTextSend",
[SendType.File]: isEditMode ? "editItemHeaderFileSend" : "newItemHeaderFileSend",
};
return this.i18nService.t(translation[type]);
}
/**

12
package-lock.json generated
View File

@@ -40,7 +40,7 @@
"buffer": "6.0.3",
"bufferutil": "4.0.9",
"chalk": "4.1.2",
"commander": "11.1.0",
"commander": "14.0.0",
"core-js": "3.45.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
@@ -203,7 +203,7 @@
"big-integer": "1.6.52",
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "11.1.0",
"commander": "14.0.0",
"core-js": "3.45.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
@@ -18366,12 +18366,12 @@
}
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
"integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
"license": "MIT",
"engines": {
"node": ">=16"
"node": ">=20"
}
},
"node_modules/common-path-prefix": {

View File

@@ -175,7 +175,7 @@
"buffer": "6.0.3",
"bufferutil": "4.0.9",
"chalk": "4.1.2",
"commander": "11.1.0",
"commander": "14.0.0",
"core-js": "3.45.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",