diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index deb7410a716..231637d7af3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -185,7 +185,7 @@ "message": "Continue to browser extension store?" }, "continueToBrowserExtensionStoreDesc": { - "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." + "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." }, "changeMasterPasswordOnWebConfirmation": { "message": "You can change your master password on the Bitwarden web app." @@ -389,6 +389,9 @@ "favorite": { "message": "Favorite" }, + "unfavorite": { + "message": "Unfavorite" + }, "notes": { "message": "Notes" }, @@ -410,6 +413,9 @@ "launch": { "message": "Launch" }, + "launchWebsite": { + "message": "Launch website" + }, "website": { "message": "Website" }, @@ -822,7 +828,7 @@ }, "exportPasswordDescription": { "message": "This password will be used to export and import this file" - }, + }, "accountRestrictedOptionDescription": { "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." }, @@ -1431,6 +1437,15 @@ "collections": { "message": "Collections" }, + "nCollections": { + "message": "$COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, "favorites": { "message": "Favorites" }, @@ -1660,6 +1675,9 @@ "autoFillAndSave": { "message": "Auto-fill and save" }, + "fillAndSave": { + "message": "Fill and save" + }, "autoFillSuccessAndSavedUri": { "message": "Item auto-filled and URI saved" }, @@ -3281,7 +3299,7 @@ "clearFiltersOrTryAnother": { "message": "Clear filters or try another search term" }, - "copyInfo": { + "copyInfoLabel": { "message": "Copy info, $ITEMNAME$", "description": "Aria label for a button that opens a menu with options to copy information from an item.", "placeholders": { @@ -3291,7 +3309,37 @@ } } }, - "moreOptions": { + "copyInfoTitle": { + "message": "Copy info - $ITEMNAME$", + "description": "Title for a button that opens a menu with options to copy information from an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "copyNoteLabel": { + "message": "Copy Note, $ITEMNAME$", + "description": "Aria label for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "copyNoteTitle": { + "message": "Copy Note - $ITEMNAME$", + "description": "Title for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "moreOptionsLabel": { "message": "More options, $ITEMNAME$", "description": "Aria label for a button that opens a menu with more options for an item.", "placeholders": { @@ -3301,6 +3349,38 @@ } } }, + "moreOptionsTitle": { + "message": "More options - $ITEMNAME$", + "description": "Title for a button that opens a menu with more options for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "viewItemTitle": { + "message": "View item - $ITEMNAME$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "assignCollections": { + "message": "Assign collections" + }, + "copyEmail": { + "message": "Copy email" + }, + "copyPhone": { + "message": "Copy phone" + }, + "copyAddress": { + "message": "Copy address" + }, "adminConsole": { "message": "Admin Console" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 0b108e8b814..83b07fc14cb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -4,7 +4,7 @@ [title]="'autofillSuggestions' | i18n" [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" - showAutoFill + showAutofillButton > diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index c00e585e739..9a4670bb4c8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -3,12 +3,12 @@ import { Component } from "@angular/core"; import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -30,7 +30,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html new file mode 100644 index 00000000000..08133c6b466 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts new file mode 100644 index 00000000000..c89fcca3b3f --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { CopyCipherFieldDirective } from "@bitwarden/vault"; + +@Component({ + standalone: true, + selector: "app-item-copy-actions", + templateUrl: "item-copy-actions.component.html", + imports: [ + ItemModule, + IconButtonModule, + JslibModule, + MenuModule, + CommonModule, + CopyCipherFieldDirective, + ], +}) +export class ItemCopyActionsComponent { + @Input() cipher: CipherView; + + protected CipherType = CipherType; + + constructor() {} +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html new file mode 100644 index 00000000000..1d7a2a8cd0c --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + {{ "clone" | i18n }} + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts new file mode 100644 index 00000000000..9834dc553ec --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -0,0 +1,122 @@ +import { CommonModule } from "@angular/common"; +import { booleanAttribute, Component, Input } from "@angular/core"; +import { Router, RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { BrowserApi } from "../../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; + +@Component({ + standalone: true, + selector: "app-item-more-options", + templateUrl: "./item-more-options.component.html", + imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], +}) +export class ItemMoreOptionsComponent { + @Input({ + required: true, + }) + cipher: CipherView; + + /** + * Flag to hide the login specific menu options. Used for login items that are + * already in the autofill list suggestion. + */ + @Input({ transform: booleanAttribute }) + hideLoginOptions: boolean; + + protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$; + + constructor( + private cipherService: CipherService, + private vaultPopupItemsService: VaultPopupItemsService, + private passwordRepromptService: PasswordRepromptService, + private dialogService: DialogService, + private router: Router, + ) {} + + get canEdit() { + return this.cipher.edit; + } + + get isLogin() { + return this.cipher.type === CipherType.Login; + } + + get favoriteText() { + return this.cipher.favorite ? "unfavorite" : "favorite"; + } + + /** + * Determines if the login cipher can be launched in a new browser tab. + */ + get canLaunch() { + return this.isLogin && this.cipher.login.canLaunch; + } + + /** + * Launches the login cipher in a new browser tab. + */ + async launchCipher() { + if (!this.canLaunch) { + return; + } + + await this.cipherService.updateLastLaunchedDate(this.cipher.id); + + await BrowserApi.createNewTab(this.cipher.login.launchUri); + + if (BrowserPopupUtils.inPopup(window)) { + BrowserApi.closePopup(window); + } + } + + /** + * Toggles the favorite status of the cipher and updates it on the server. + */ + async toggleFavorite() { + this.cipher.favorite = !this.cipher.favorite; + const encryptedCipher = await this.cipherService.encrypt(this.cipher); + await this.cipherService.updateWithServer(encryptedCipher); + } + + /** + * Navigate to the clone cipher page with the current cipher as the source. + * A password reprompt is attempted if the cipher requires it. + * A confirmation dialog is shown if the cipher has FIDO2 credentials. + */ + async clone() { + if ( + this.cipher.reprompt === CipherRepromptType.Password && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return; + } + + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return; + } + } + + await this.router.navigate(["/clone-cipher"], { + queryParams: { + cloneMode: true, + cipherId: this.cipher.id, + }, + }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index d3bb85c710f..74ee1af1ff5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -13,31 +13,31 @@ - + {{ cipher.name }} + {{ cipher.subTitle }} - + - - - - - - + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index f9b34e96162..311619fa0ff 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -3,7 +3,7 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul import { RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeModule, ButtonModule, @@ -14,6 +14,9 @@ import { } from "@bitwarden/components"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; +import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; +import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; @Component({ imports: [ @@ -27,6 +30,8 @@ import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup JslibModule, PopupSectionHeaderComponent, RouterLink, + ItemCopyActionsComponent, + ItemMoreOptionsComponent, ], selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", @@ -37,7 +42,7 @@ export class VaultListItemsContainerComponent { * The list of ciphers to display. */ @Input() - ciphers: CipherView[]; + ciphers: PopupCipherView[] = []; /** * Title for the vault list item section. @@ -61,5 +66,19 @@ export class VaultListItemsContainerComponent { * Option to show the autofill button for each item. */ @Input({ transform: booleanAttribute }) - showAutoFill: boolean; + showAutofillButton: boolean; + + /** + * The tooltip text for the organization icon for ciphers that belong to an organization. + * @param cipher + */ + orgIconTooltip(cipher: PopupCipherView) { + if (cipher.collectionIds.length > 1) { + return this.i18nService.t("nCollections", cipher.collectionIds.length); + } + + return cipher.collections[0]?.name; + } + + constructor(private i18nService: I18nService) {} } diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 4d75685f538..f99d3cbb303 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -22,10 +22,12 @@ - - +
+ + - + +
{ + let testBed: TestBed; let service: VaultPopupItemsService; let allCiphers: Record; let autoFillCiphers: CipherView[]; + let mockOrg: Organization; + let mockCollections: CollectionView[]; + const cipherServiceMock = mock(); const vaultSettingsServiceMock = mock(); const organizationServiceMock = mock(); const vaultPopupListFiltersServiceMock = mock(); const searchService = mock(); + const collectionService = mock(); beforeEach(() => { allCiphers = cipherFactory(10); @@ -39,11 +49,14 @@ describe("VaultPopupItemsService", () => { cipherList[2].favorite = true; cipherList[3].favorite = true; - cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); - searchService.searchCiphers.mockImplementation(async () => cipherList); - cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); + cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable(); + searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers); + cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) => + ciphers.filter((c) => ["0", "1"].includes(c.id)), + ); + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false); vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ organization: null, @@ -55,31 +68,61 @@ describe("VaultPopupItemsService", () => { vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( (ciphers: CipherView[]) => ciphers, ); - jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest .spyOn(BrowserApi, "getTabFromCurrentWindow") .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + + mockOrg = { + id: "org1", + name: "Organization 1", + planProductType: ProductType.Enterprise, + } as Organization; + + mockCollections = [ + { id: "col1", name: "Collection 1" } as CollectionView, + { id: "col2", name: "Collection 2" } as CollectionView, + ]; + + organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]); + collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections); + + testBed = TestBed.configureTestingModule({ + providers: [ + { provide: CipherService, useValue: cipherServiceMock }, + { provide: VaultSettingsService, useValue: vaultSettingsServiceMock }, + { provide: SearchService, useValue: searchService }, + { provide: OrganizationService, useValue: organizationServiceMock }, + { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, + { provide: CollectionService, useValue: collectionService }, + ], + }); + + service = testBed.inject(VaultPopupItemsService); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it("should be created", () => { - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + service = testBed.inject(VaultPopupItemsService); expect(service).toBeTruthy(); }); + it("should merge cipher views with collections and organization", (done) => { + const cipherList = Object.values(allCiphers); + cipherList[0].organizationId = "org1"; + cipherList[0].collectionIds = ["col1", "col2"]; + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers[0].organization).toEqual(mockOrg); + expect(ciphers[0].collections).toContain(mockCollections[0]); + expect(ciphers[0].collections).toContain(mockCollections[1]); + done(); + }); + }); + describe("autoFillCiphers$", () => { it("should return empty array if there is no current tab", (done) => { jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); @@ -100,18 +143,10 @@ describe("VaultPopupItemsService", () => { it("should filter ciphers for the current tab and types", (done) => { const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); + (vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject).next(true); + (vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject).next(true); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( @@ -136,14 +171,6 @@ describe("VaultPopupItemsService", () => { Object.values(allCiphers), ); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers.length).toBe(10); @@ -159,19 +186,18 @@ describe("VaultPopupItemsService", () => { }); it("should filter autoFillCiphers$ down to search term", (done) => { - const cipherList = Object.values(allCiphers); const searchText = "Login"; - searchService.searchCiphers.mockImplementation(async () => { - return cipherList.filter((cipher) => { + searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => { + return ciphers.filter((cipher) => { return cipher.name.includes(searchText); }); }); - // there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types + // there is only 1 Login returned for filteredCiphers. service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers[0].name.includes(searchText)).toBe(true); - expect(ciphers.length).toBe(2); + expect(ciphers.length).toBe(1); done(); }); }); @@ -248,14 +274,7 @@ describe("VaultPopupItemsService", () => { describe("emptyVault$", () => { it("should return true if there are no ciphers", (done) => { - cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - vaultPopupListFiltersServiceMock, - organizationServiceMock, - searchService, - ); + cipherServiceMock.getAllDecrypted.mockResolvedValue([]); service.emptyVault$.subscribe((empty) => { expect(empty).toBe(true); done(); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index f9c37f6f7da..eacb8e013ef 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable, NgZone } from "@angular/core"; import { BehaviorSubject, combineLatest, @@ -15,13 +15,18 @@ import { import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopupCipherView } from "../views/popup-cipher.view"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; @@ -72,12 +77,33 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _cipherList$: Observable = this.cipherService.cipherViews$.pipe( + private _cipherList$: Observable = this.cipherService.ciphers$.pipe( + runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular + switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), map((ciphers) => Object.values(ciphers)), - shareReplay({ refCount: false, bufferSize: 1 }), + switchMap((ciphers) => + combineLatest([ + this.organizationService.organizations$, + this.collectionService.decryptedCollections$, + ]).pipe( + map(([organizations, collections]) => { + const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); + const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); + return ciphers.map( + (cipher) => + new PopupCipherView( + cipher, + cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), + orgMap[cipher.organizationId as OrganizationId], + ), + ); + }), + ), + ), + shareReplay({ refCount: true, bufferSize: 1 }), ); - private _filteredCipherList$: Observable = combineLatest([ + private _filteredCipherList$: Observable = combineLatest([ this._cipherList$, this.searchText$, this.vaultPopupListFiltersService.filterFunction$, @@ -86,8 +112,9 @@ export class VaultPopupItemsService { filterFunction(ciphers), searchText, ]), - switchMap(([ciphers, searchText]) => - this.searchService.searchCiphers(searchText, null, ciphers), + switchMap( + ([ciphers, searchText]) => + this.searchService.searchCiphers(searchText, null, ciphers) as Promise, ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -98,7 +125,7 @@ export class VaultPopupItemsService { * * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. */ - autoFillCiphers$: Observable = combineLatest([ + autoFillCiphers$: Observable = combineLatest([ this._filteredCipherList$, this._otherAutoFillTypes$, this._currentAutofillTab$, @@ -117,7 +144,7 @@ export class VaultPopupItemsService { * List of favorite ciphers that are not currently suggested for autofill. * Ciphers are sorted by last used date, then by name. */ - favoriteCiphers$: Observable = combineLatest([ + favoriteCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this._filteredCipherList$, ]).pipe( @@ -134,7 +161,7 @@ export class VaultPopupItemsService { * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * Ciphers are sorted by name. */ - remainingCiphers$: Observable = combineLatest([ + remainingCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this.favoriteCiphers$, this._filteredCipherList$, @@ -204,6 +231,7 @@ export class VaultPopupItemsService { private vaultPopupListFiltersService: VaultPopupListFiltersService, private organizationService: OrganizationService, private searchService: SearchService, + private collectionService: CollectionService, ) {} /** diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index eba8f94f12e..42626b52918 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -169,6 +169,13 @@ describe("VaultPopupListFiltersService", () => { expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]); }); }); + + it("sets collection icon", (done) => { + service.collections$.subscribe((collections) => { + expect(collections.every(({ icon }) => icon === "bwi-collection")).toBeTruthy(); + done(); + }); + }); }); describe("folders$", () => { @@ -210,6 +217,22 @@ describe("VaultPopupListFiltersService", () => { }); }); + it("sets folder icon", (done) => { + service.filterForm.patchValue({ + organization: { id: MY_VAULT_ID } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.every(({ icon }) => icon === "bwi-folder")).toBeTruthy(); + done(); + }); + }); + it("returns folders that have ciphers within the selected organization", (done) => { service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => { expect(folders.map((f) => f.label)).toEqual(["Folder 1"]); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index f3522aa8e33..bc42e7cb0a5 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -206,7 +206,7 @@ export class VaultPopupListFiltersService { /** * Folder array structured to be directly passed to `ChipSelectComponent` */ - folders$: Observable[]> = combineLatest([ + folders$: Observable[]> = combineLatest([ this.filters$.pipe( distinctUntilChanged( (previousFilter, currentFilter) => @@ -258,13 +258,15 @@ export class VaultPopupListFiltersService { nestedList: nestedFolders, }); }), - map((folders) => folders.nestedList.map(this.convertToChipSelectOption.bind(this))), + map((folders) => + folders.nestedList.map((f) => this.convertToChipSelectOption(f, "bwi-folder")), + ), ); /** * Collection array structured to be directly passed to `ChipSelectComponent` */ - collections$: Observable[]> = combineLatest([ + collections$: Observable[]> = combineLatest([ this.filters$.pipe( distinctUntilChanged( (previousFilter, currentFilter) => @@ -292,7 +294,9 @@ export class VaultPopupListFiltersService { nestedList: nestedCollections, }); }), - map((collections) => collections.nestedList.map(this.convertToChipSelectOption.bind(this))), + map((collections) => + collections.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection")), + ), ); /** @@ -300,13 +304,14 @@ export class VaultPopupListFiltersService { */ private convertToChipSelectOption( item: TreeNode, + icon: string, ): ChipSelectOption { return { value: item.node, label: item.node.name, - icon: "bwi-folder", // Organization & Folder icons are the same + icon, children: item.children - ? item.children.map(this.convertToChipSelectOption.bind(this)) + ? item.children.map((i) => this.convertToChipSelectOption(i, icon)) : undefined, }; } diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts new file mode 100644 index 00000000000..4707eb9eb0f --- /dev/null +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -0,0 +1,41 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; + +/** + * Extended cipher view for the popup. Includes the associated collections and organization + * if applicable. + */ +export class PopupCipherView extends CipherView { + collections?: CollectionView[]; + organization?: Organization; + + constructor( + cipher: CipherView, + collections: CollectionView[] = null, + organization: Organization = null, + ) { + super(); + Object.assign(this, cipher); + this.collections = collections; + this.organization = organization; + } + + /** + * Get the bwi icon for the cipher according to the organization type. + */ + get orgIcon(): "bwi-family" | "bwi-business" | null { + switch (this.organization?.planProductType) { + case ProductType.Free: + case ProductType.Families: + return "bwi-family"; + case ProductType.Teams: + case ProductType.Enterprise: + case ProductType.TeamsStarter: + return "bwi-business"; + default: + return null; + } + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 149277b8179..2a092e26100 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -144,6 +144,7 @@ { - return async () => { - const dialogRef = openAdjustStorageDialog(this.dialogService, { - data: { - storageGbPrice: 4, - add: add, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AdjustStorageDialogResult.Adjusted) { - await this.load(); - } - }; + adjustStorage = async (add: boolean) => { + const dialogRef = openAdjustStorageDialog(this.dialogService, { + data: { + storageGbPrice: 4, + add: add, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AdjustStorageDialogResult.Adjusted) { + await this.load(); + } }; get subscriptionMarkedForCancel() { diff --git a/apps/web/src/app/core/router.service.ts b/apps/web/src/app/core/router.service.ts index c0c1ec26407..2944732aee6 100644 --- a/apps/web/src/app/core/router.service.ts +++ b/apps/web/src/app/core/router.service.ts @@ -12,6 +12,14 @@ import { GlobalState, } from "@bitwarden/common/platform/state"; +/** + * Data properties acceptable for use in route objects (see usage in oss-routing.module.ts for example) + */ +export interface DataProperties { + titleId?: string; // sets the title of the current HTML document (shows in browser tab) + doNotSaveUrl?: boolean; // choose to not keep track of the previous URL in memory +} + const DEEP_LINK_REDIRECT_URL = new KeyDefinition(ROUTER_DISK, "deepLinkRedirectUrl", { deserializer: (value: string) => value, }); diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index e543a6f0835..c7b4631fa30 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -40,6 +40,7 @@ import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component"; +import { DataProperties } from "./core"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { DomainRulesComponent } from "./settings/domain-rules.component"; @@ -54,7 +55,7 @@ const routes: Routes = [ { path: "", component: FrontendLayoutComponent, - data: { doNotSaveUrl: true }, + data: { doNotSaveUrl: true } satisfies DataProperties, children: [ { path: "", @@ -66,17 +67,17 @@ const routes: Routes = [ { path: "login-with-device", component: LoginViaAuthRequestComponent, - data: { titleId: "loginWithDevice" }, + data: { titleId: "loginWithDevice" } satisfies DataProperties, }, { path: "login-with-passkey", component: LoginViaWebAuthnComponent, - data: { titleId: "loginWithPasskey" }, + data: { titleId: "loginWithPasskey" } satisfies DataProperties, }, { path: "admin-approval-requested", component: LoginViaAuthRequestComponent, - data: { titleId: "adminApprovalRequested" }, + data: { titleId: "adminApprovalRequested" } satisfies DataProperties, }, { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { @@ -88,7 +89,7 @@ const routes: Routes = [ path: "register", component: TrialInitiationComponent, canActivate: [UnauthGuard], - data: { titleId: "createAccount" }, + data: { titleId: "createAccount" } satisfies DataProperties, }, { path: "trial", @@ -99,18 +100,18 @@ const routes: Routes = [ path: "sso", component: SsoComponent, canActivate: [UnauthGuard], - data: { titleId: "enterpriseSingleSignOn" }, + data: { titleId: "enterpriseSingleSignOn" } satisfies DataProperties, }, { path: "set-password", component: SetPasswordComponent, - data: { titleId: "setMasterPassword" }, + data: { titleId: "setMasterPassword" } satisfies DataProperties, }, { path: "hint", component: HintComponent, canActivate: [UnauthGuard], - data: { titleId: "passwordHint" }, + data: { titleId: "passwordHint" } satisfies DataProperties, }, { path: "lock", @@ -122,12 +123,12 @@ const routes: Routes = [ path: "accept-organization", canActivate: [deepLinkGuard()], component: AcceptOrganizationComponent, - data: { titleId: "joinOrganization", doNotSaveUrl: false }, + data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies DataProperties, }, { path: "accept-emergency", canActivate: [deepLinkGuard()], - data: { titleId: "acceptEmergency", doNotSaveUrl: false }, + data: { titleId: "acceptEmergency", doNotSaveUrl: false } satisfies DataProperties, loadComponent: () => import("./auth/emergency-access/accept/accept-emergency.component").then( (mod) => mod.AcceptEmergencyComponent, @@ -137,26 +138,26 @@ const routes: Routes = [ path: "accept-families-for-enterprise", component: AcceptFamilySponsorshipComponent, canActivate: [deepLinkGuard()], - data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false }, + data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false } satisfies DataProperties, }, { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, { path: "recover-2fa", component: RecoverTwoFactorComponent, canActivate: [UnauthGuard], - data: { titleId: "recoverAccountTwoStep" }, + data: { titleId: "recoverAccountTwoStep" } satisfies DataProperties, }, { path: "recover-delete", component: RecoverDeleteComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "verify-recover-delete", component: VerifyRecoverDeleteComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "verify-recover-delete-org", @@ -168,30 +169,30 @@ const routes: Routes = [ path: "verify-recover-delete-provider", component: VerifyRecoverDeleteProviderComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "send/:sendId/:key", component: AccessComponent, - data: { title: "Bitwarden Send" }, + data: { titleId: "Bitwarden Send" } satisfies DataProperties, }, { path: "update-temp-password", component: UpdateTempPasswordComponent, canActivate: [AuthGuard], - data: { titleId: "updateTempPassword" }, + data: { titleId: "updateTempPassword" } satisfies DataProperties, }, { path: "update-password", component: UpdatePasswordComponent, canActivate: [AuthGuard], - data: { titleId: "updatePassword" }, + data: { titleId: "updatePassword" } satisfies DataProperties, }, { path: "remove-password", component: RemovePasswordComponent, canActivate: [AuthGuard], - data: { titleId: "removeMasterPassword" }, + data: { titleId: "removeMasterPassword" } satisfies DataProperties, }, { path: "migrate-legacy-encryption", @@ -211,21 +212,29 @@ const routes: Routes = [ path: "vault", loadChildren: () => VaultModule, }, - { path: "sends", component: SendComponent, data: { titleId: "send" } }, + { + path: "sends", + component: SendComponent, + data: { titleId: "send" } satisfies DataProperties, + }, { path: "create-organization", component: CreateOrganizationComponent, - data: { titleId: "newOrganization" }, + data: { titleId: "newOrganization" } satisfies DataProperties, }, { path: "settings", children: [ { path: "", pathMatch: "full", redirectTo: "account" }, - { path: "account", component: AccountComponent, data: { titleId: "myAccount" } }, + { + path: "account", + component: AccountComponent, + data: { titleId: "myAccount" } satisfies DataProperties, + }, { path: "preferences", component: PreferencesComponent, - data: { titleId: "preferences" }, + data: { titleId: "preferences" } satisfies DataProperties, }, { path: "security", @@ -234,7 +243,7 @@ const routes: Routes = [ { path: "domain-rules", component: DomainRulesComponent, - data: { titleId: "domainRules" }, + data: { titleId: "domainRules" } satisfies DataProperties, }, { path: "subscription", @@ -249,19 +258,19 @@ const routes: Routes = [ { path: "", component: EmergencyAccessComponent, - data: { titleId: "emergencyAccess" }, + data: { titleId: "emergencyAccess" } satisfies DataProperties, }, { path: ":id", component: EmergencyAccessViewComponent, - data: { titleId: "emergencyAccess" }, + data: { titleId: "emergencyAccess" } satisfies DataProperties, }, ], }, { path: "sponsored-families", component: SponsoredFamiliesComponent, - data: { titleId: "sponsoredFamilies" }, + data: { titleId: "sponsoredFamilies" } satisfies DataProperties, }, ], }, @@ -276,7 +285,7 @@ const routes: Routes = [ import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent), data: { titleId: "importData", - }, + } satisfies DataProperties, }, { path: "export", @@ -286,7 +295,7 @@ const routes: Routes = [ { path: "generator", component: GeneratorComponent, - data: { titleId: "generator" }, + data: { titleId: "generator" } satisfies DataProperties, }, ], }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 95c17642538..56c02e1ed43 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -17,6 +17,7 @@ import { import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -94,6 +95,7 @@ export class OverviewComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, + private logService: LogService, ) {} ngOnInit() { @@ -297,12 +299,13 @@ export class OverviewComponent implements OnInit, OnDestroy { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } @@ -310,11 +313,9 @@ export class OverviewComponent implements OnInit, OnDestroy { SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); } - protected hideOnboarding() { + protected async hideOnboarding() { this.showOnboarding = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.saveCompletedTasks(this.organizationId, { + await this.saveCompletedTasks(this.organizationId, { importSecrets: true, createSecret: true, createProject: true, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 0b65bd0a26b..d30d5f664e2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -82,9 +82,7 @@ export class ProjectDialogComponent implements OnInit { const projectView = this.getProjectView(); if (this.data.operation === OperationType.Add) { const newProject = await this.createProject(projectView); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]); + await this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]); } else { projectView.id = this.data.projectId; await this.updateProject(projectView); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts new file mode 100644 index 00000000000..84bc1483fd1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts @@ -0,0 +1,120 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { RouterService } from "../../../../../../../apps/web/src/app/core/router.service"; +import { ProjectView } from "../../models/view/project.view"; +import { ProjectService } from "../project.service"; + +import { projectAccessGuard } from "./project-access.guard"; + +@Component({ + template: "", +}) +export class GuardedRouteTestComponent {} + +@Component({ + template: "", +}) +export class RedirectTestComponent {} + +describe("Project Redirect Guard", () => { + let organizationService: MockProxy; + let routerService: MockProxy; + let projectServiceMock: MockProxy; + let i18nServiceMock: MockProxy; + let platformUtilsService: MockProxy; + let router: Router; + + const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization; + const projectView = { + id: "123", + organizationId: "123", + name: "project-name", + creationDate: Date.now.toString(), + revisionDate: Date.now.toString(), + read: true, + write: true, + } as ProjectView; + + beforeEach(async () => { + organizationService = mock(); + routerService = mock(); + projectServiceMock = mock(); + i18nServiceMock = mock(); + platformUtilsService = mock(); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { + path: "sm/:organizationId/projects/:projectId", + component: GuardedRouteTestComponent, + canActivate: [projectAccessGuard], + }, + { + path: "sm", + component: RedirectTestComponent, + }, + { + path: "sm/:organizationId/projects", + component: RedirectTestComponent, + }, + ]), + ], + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: RouterService, useValue: routerService }, + { provide: ProjectService, useValue: projectServiceMock }, + { provide: I18nService, useValue: i18nServiceMock }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + }); + + router = TestBed.inject(Router); + }); + + it("redirects to sm/{orgId}/projects/{projectId} if project exists", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + projectServiceMock.getByProjectId.mockReturnValue(Promise.resolve(projectView)); + + // Act + await router.navigateByUrl("sm/123/projects/123"); + + // Assert + expect(router.url).toBe("/sm/123/projects/123"); + }); + + it("redirects to sm/projects if project does not exist", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + + // Act + await router.navigateByUrl("sm/123/projects/124"); + + // Assert + expect(router.url).toBe("/sm/123/projects"); + }); + + it("redirects to sm/123/projects if exception occurs while looking for Project", async () => { + // Arrange + jest.spyOn(projectServiceMock, "getByProjectId").mockImplementation(() => { + throw new Error("Test error"); + }); + jest.spyOn(i18nServiceMock, "t").mockReturnValue("Project not found"); + + // Act + await router.navigateByUrl("sm/123/projects/123"); + // Assert + expect(platformUtilsService.showToast).toHaveBeenCalledWith("error", null, "Project not found"); + expect(router.url).toBe("/sm/123/projects"); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts new file mode 100644 index 00000000000..6c08fcc3aa7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts @@ -0,0 +1,31 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ProjectService } from "../project.service"; + +/** + * Redirects to projects list if the user doesn't have access to project. + */ +export const projectAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const projectService = inject(ProjectService); + const platformUtilsService = inject(PlatformUtilsService); + const i18nService = inject(I18nService); + + try { + const project = await projectService.getByProjectId(route.params.projectId); + if (project) { + return true; + } + } catch { + platformUtilsService.showToast( + "error", + null, + i18nService.t("notFound", i18nService.t("project")), + ); + return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]); + } + return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]); +}; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts index 835d3825a05..c49008c580b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { DialogService } from "@bitwarden/components"; @@ -38,8 +39,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { }), ), catchError(async () => { + this.logService.info("Error fetching project people access policies."); await this.router.navigate(["/sm", this.organizationId, "projects"]); - return []; + return undefined; }), ); @@ -70,6 +72,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private accessPolicySelectorService: AccessPolicySelectorService, + private logService: LogService, ) {} ngOnInit(): void { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 07d50b28ee1..21d6e576a01 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -4,6 +4,7 @@ import { combineLatest, combineLatestWith, filter, Observable, startWith, switch import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -42,6 +43,7 @@ export class ProjectSecretsComponent { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private organizationService: OrganizationService, + private logService: LogService, ) {} ngOnInit() { @@ -109,12 +111,13 @@ export class ProjectSecretsComponent { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index 742c2bea1d8..07ca32600a9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -1,9 +1,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { - catchError, combineLatest, - EMPTY, filter, Observable, startWith, @@ -58,18 +56,6 @@ export class ProjectComponent implements OnInit, OnDestroy { this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe( switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)), - catchError(() => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/sm", this.organizationId, "projects"]).then(() => { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("notFound", this.i18nService.t("project")), - ); - }); - return EMPTY; - }), ); const projectId$ = this.route.params.pipe(map((p) => p.projectId)); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts index 6078520989a..231486703c9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { projectAccessGuard } from "./guards/project-access.guard"; import { ProjectPeopleComponent } from "./project/project-people.component"; import { ProjectSecretsComponent } from "./project/project-secrets.component"; import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component"; @@ -15,6 +16,7 @@ const routes: Routes = [ { path: ":projectId", component: ProjectComponent, + canActivate: [projectAccessGuard], children: [ { path: "", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index b1bd91a04fb..0287cdd4251 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -199,7 +199,7 @@ export class SecretDialogComponent implements OnInit { return await this.projectService.create(this.data.organizationId, projectView); } - protected openDeleteSecretDialog() { + protected async openDeleteSecretDialog() { const secretListView: SecretListView[] = this.getSecretListView(); const dialogRef = this.dialogService.open( @@ -212,9 +212,7 @@ export class SecretDialogComponent implements OnInit { ); // If the secret is deleted, chain close this dialog after the delete dialog - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - lastValueFrom(dialogRef.closed).then( + await lastValueFrom(dialogRef.closed).then( (closeData) => closeData !== undefined && this.dialogRef.close(), ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index a7413c9b59f..2717f96a686 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -4,6 +4,7 @@ import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -39,6 +40,7 @@ export class SecretsComponent implements OnInit { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private organizationService: OrganizationService, + private logService: LogService, ) {} ngOnInit() { @@ -97,12 +99,13 @@ export class SecretsComponent implements OnInit { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 105ca59e57f..de753d88138 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -47,9 +47,7 @@ export class ServiceAccountDialogComponent { async ngOnInit() { if (this.data.operation == OperationType.Edit) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.loadData(); + await this.loadData(); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts new file mode 100644 index 00000000000..956935ac6ac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts @@ -0,0 +1,122 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { RouterService } from "../../../../../../../../clients/apps/web/src/app/core/router.service"; +import { ServiceAccountView } from "../../models/view/service-account.view"; +import { ServiceAccountService } from "../service-account.service"; + +import { serviceAccountAccessGuard } from "./service-account-access.guard"; + +@Component({ + template: "", +}) +export class GuardedRouteTestComponent {} + +@Component({ + template: "", +}) +export class RedirectTestComponent {} + +describe("Service account Redirect Guard", () => { + let organizationService: MockProxy; + let routerService: MockProxy; + let serviceAccountServiceMock: MockProxy; + let i18nServiceMock: MockProxy; + let platformUtilsService: MockProxy; + let router: Router; + + const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization; + const serviceAccountView = { + id: "123", + organizationId: "123", + name: "service-account-name", + } as ServiceAccountView; + + beforeEach(async () => { + organizationService = mock(); + routerService = mock(); + serviceAccountServiceMock = mock(); + i18nServiceMock = mock(); + platformUtilsService = mock(); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { + path: "sm/:organizationId/machine-accounts/:serviceAccountId", + component: GuardedRouteTestComponent, + canActivate: [serviceAccountAccessGuard], + }, + { + path: "sm", + component: RedirectTestComponent, + }, + { + path: "sm/:organizationId/machine-accounts", + component: RedirectTestComponent, + }, + ]), + ], + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: RouterService, useValue: routerService }, + { provide: ServiceAccountService, useValue: serviceAccountServiceMock }, + { provide: I18nService, useValue: i18nServiceMock }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + }); + + router = TestBed.inject(Router); + }); + + it("redirects to sm/{orgId}/machine-accounts/{serviceAccountId} if machine account exists", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + serviceAccountServiceMock.getByServiceAccountId.mockReturnValue( + Promise.resolve(serviceAccountView), + ); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/123"); + + // Assert + expect(router.url).toBe("/sm/123/machine-accounts/123"); + }); + + it("redirects to sm/machine-accounts if machine account does not exist", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/124"); + + // Assert + expect(router.url).toBe("/sm/123/machine-accounts"); + }); + + it("redirects to sm/123/machine-accounts if exception occurs while looking for service account", async () => { + // Arrange + jest.spyOn(serviceAccountServiceMock, "getByServiceAccountId").mockImplementation(() => { + throw new Error("Test error"); + }); + jest.spyOn(i18nServiceMock, "t").mockReturnValue("Service account not found"); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/123"); + // Assert + expect(platformUtilsService.showToast).toHaveBeenCalledWith( + "error", + null, + "Service account not found", + ); + expect(router.url).toBe("/sm/123/machine-accounts"); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts index c474ec44d55..b72fc5a1fe2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts @@ -1,6 +1,9 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + import { ServiceAccountService } from "../service-account.service"; /** @@ -8,6 +11,8 @@ import { ServiceAccountService } from "../service-account.service"; */ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { const serviceAccountService = inject(ServiceAccountService); + const platformUtilsService = inject(PlatformUtilsService); + const i18nService = inject(I18nService); try { const serviceAccount = await serviceAccountService.getByServiceAccountId( @@ -18,6 +23,12 @@ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedR return true; } } catch { + platformUtilsService.showToast( + "error", + null, + i18nService.t("notFound", i18nService.t("machineAccount")), + ); + return createUrlTreeFromSnapshot(route, [ "/sm", route.params.organizationId, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index bb687c51c62..51b663acce6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -1,15 +1,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { - EMPTY, - Subject, - catchError, - combineLatest, - filter, - startWith, - switchMap, - takeUntil, -} from "rxjs"; +import { Subject, combineLatest, filter, startWith, switchMap, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,18 +33,6 @@ export class ServiceAccountComponent implements OnInit, OnDestroy { params.organizationId, ), ), - catchError(() => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/sm", this.organizationId, "machine-accounts"]).then(() => { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("notFound", this.i18nService.t("machineAccount")), - ); - }); - return EMPTY; - }), ); constructor( diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html index f8d5d1081e0..4b629ca4885 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html @@ -93,8 +93,9 @@ variant="secondary" class="tw-ml-1" [title]="project.name" + maxWidthClass="tw-max-w-60" > - {{ project.name | ellipsis: 32 }} + {{ project.name }} secret.value); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - SecretsListComponent.copyToClipboardAsync(value, platformUtilsService).then(() => { + try { + const value = await secretService.getBySecretId(id).then((secret) => secret.value); + platformUtilsService.copyToClipboard(value); platformUtilsService.showToast( "success", null, i18nService.t("valueCopied", i18nService.t("value")), ); - }); + } catch { + logService.info("Error fetching secret value."); + } } static copySecretUuid( diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index bf5edbda823..b6eeb70d5d5 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -13,9 +13,11 @@

{{ subtitle }}

-
+
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts index 82ca846afbf..c9054fb5e63 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts @@ -119,6 +119,24 @@ export const WithLongContent: Story = { }), }; +export const WithThinPrimaryContent: Story = { + render: (args) => ({ + props: args, + template: + // Projected content (the
's) and styling is just a sample and can be replaced with any content/styling. + ` + +
Lorem ipsum
+ +
+
Secondary Projected Content (optional)
+ +
+
+ `, + }), +}; + export const WithIcon: Story = { render: (args) => ({ props: args, diff --git a/libs/common/spec/observable-tracker.ts b/libs/common/spec/observable-tracker.ts index 16fad869c3b..9bf0475bee2 100644 --- a/libs/common/spec/observable-tracker.ts +++ b/libs/common/spec/observable-tracker.ts @@ -1,10 +1,11 @@ -import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; +import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; /** Test class to enable async awaiting of observable emissions */ export class ObservableTracker { private subscription: Subscription; + private emissionReceived = new Subject(); emissions: T[] = []; - constructor(private observable: Observable) { + constructor(observable: Observable) { this.emissions = this.trackEmissions(observable); } @@ -21,7 +22,7 @@ export class ObservableTracker { */ async expectEmission(msTimeout = 50): Promise { return await firstValueFrom( - this.observable.pipe( + this.emissionReceived.pipe( timeout({ first: msTimeout, with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")), @@ -34,40 +35,38 @@ export class ObservableTracker { * @param count The number of emissions to wait for */ async pauseUntilReceived(count: number, msTimeout = 50): Promise { - for (let i = 0; i < count - this.emissions.length; i++) { + while (this.emissions.length < count) { await this.expectEmission(msTimeout); } return this.emissions; } - private trackEmissions(observable: Observable): T[] { + private trackEmissions(observable: Observable): T[] { const emissions: T[] = []; this.subscription = observable.subscribe((value) => { - switch (value) { - case undefined: - case null: - emissions.push(value); - return; - default: - // process by type - break; + if (value == null) { + this.emissionReceived.next(null); + return; } switch (typeof value) { case "string": case "number": case "boolean": - emissions.push(value); + this.emissionReceived.next(value); break; case "symbol": // Cheating types to make symbols work at all - emissions.push(value.toString() as T); + this.emissionReceived.next(value as T); break; default: { - emissions.push(clone(value)); + this.emissionReceived.next(clone(value)); } } }); + this.emissionReceived.subscribe((value) => { + emissions.push(value); + }); return emissions; } } diff --git a/libs/common/src/platform/services/app-id.service.spec.ts b/libs/common/src/platform/services/app-id.service.spec.ts index 10fb153fdaa..62806204db0 100644 --- a/libs/common/src/platform/services/app-id.service.spec.ts +++ b/libs/common/src/platform/services/app-id.service.spec.ts @@ -1,20 +1,23 @@ -import { FakeGlobalStateProvider } from "../../../spec"; +import { FakeGlobalState, FakeGlobalStateProvider, ObservableTracker } from "../../../spec"; import { Utils } from "../misc/utils"; import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service"; describe("AppIdService", () => { - const globalStateProvider = new FakeGlobalStateProvider(); - const appIdState = globalStateProvider.getFake(APP_ID_KEY); - const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); + let globalStateProvider: FakeGlobalStateProvider; + let appIdState: FakeGlobalState; + let anonymousAppIdState: FakeGlobalState; let sut: AppIdService; beforeEach(() => { + globalStateProvider = new FakeGlobalStateProvider(); + appIdState = globalStateProvider.getFake(APP_ID_KEY); + anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); sut = new AppIdService(globalStateProvider); }); afterEach(() => { - jest.restoreAllMocks(); + jest.resetAllMocks(); }); describe("getAppId", () => { @@ -26,19 +29,18 @@ describe("AppIdService", () => { expect(appId).toBe("existingAppId"); }); - it.each([null, undefined])( - "uses the util function to create a new id when it AppId does not exist", - async (value) => { - appIdState.stateSubject.next(value); - const spy = jest.spyOn(Utils, "newGuid"); + it("creates a new appId only once", async () => { + appIdState.stateSubject.next(null); - await sut.getAppId(); + const appIds: string[] = []; + const promises = [async () => appIds.push(await sut.getAppId())]; + promises.push(async () => appIds.push(await sut.getAppId())); + await Promise.all(promises); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); + expect(appIds[0]).toBe(appIds[1]); + }); - it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { + it.each([null, undefined])("returns a new appId when %s", async (value) => { appIdState.stateSubject.next(value); const appId = await sut.getAppId(); @@ -46,16 +48,23 @@ describe("AppIdService", () => { expect(appId).toMatch(Utils.guidRegex); }); - it.each([null, undefined])( - "stores the new guid when it an existing one is not found", - async (value) => { - appIdState.stateSubject.next(value); + it.each([null, undefined])("stores the new guid when %s", async (value) => { + appIdState.stateSubject.next(value); - const appId = await sut.getAppId(); + const appId = await sut.getAppId(); - expect(appIdState.nextMock).toHaveBeenCalledWith(appId); - }, - ); + expect(appIdState.nextMock).toHaveBeenCalledWith(appId); + }); + + it("emits only once when creating a new appId", async () => { + appIdState.stateSubject.next(null); + + const tracker = new ObservableTracker(sut.appId$); + const appId = await sut.getAppId(); + + expect(tracker.emissions).toEqual([appId]); + await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); + }); }); describe("getAnonymousAppId", () => { @@ -67,17 +76,16 @@ describe("AppIdService", () => { expect(appId).toBe("existingAppId"); }); - it.each([null, undefined])( - "uses the util function to create a new id when it AppId does not exist", - async (value) => { - anonymousAppIdState.stateSubject.next(value); - const spy = jest.spyOn(Utils, "newGuid"); + it("creates a new anonymousAppId only once", async () => { + anonymousAppIdState.stateSubject.next(null); - await sut.getAnonymousAppId(); + const appIds: string[] = []; + const promises = [async () => appIds.push(await sut.getAnonymousAppId())]; + promises.push(async () => appIds.push(await sut.getAnonymousAppId())); + await Promise.all(promises); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); + expect(appIds[0]).toBe(appIds[1]); + }); it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { anonymousAppIdState.stateSubject.next(value); @@ -97,5 +105,15 @@ describe("AppIdService", () => { expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId); }, ); + + it("emits only once when creating a new anonymousAppId", async () => { + anonymousAppIdState.stateSubject.next(null); + + const tracker = new ObservableTracker(sut.anonymousAppId$); + const appId = await sut.getAnonymousAppId(); + + expect(tracker.emissions).toEqual([appId]); + await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); + }); }); }); diff --git a/libs/common/src/platform/services/app-id.service.ts b/libs/common/src/platform/services/app-id.service.ts index 630e629749e..56e9516bce7 100644 --- a/libs/common/src/platform/services/app-id.service.ts +++ b/libs/common/src/platform/services/app-id.service.ts @@ -1,4 +1,4 @@ -import { Observable, filter, firstValueFrom, tap } from "rxjs"; +import { Observable, concatMap, distinctUntilChanged, firstValueFrom, share } from "rxjs"; import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service"; import { Utils } from "../misc/utils"; @@ -19,20 +19,28 @@ export class AppIdService implements AppIdServiceAbstraction { const appIdState = globalStateProvider.get(APP_ID_KEY); const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY); this.appId$ = appIdState.state$.pipe( - tap(async (appId) => { + concatMap(async (appId) => { if (!appId) { - await appIdState.update(() => Utils.newGuid()); + return await appIdState.update(() => Utils.newGuid(), { + shouldUpdate: (v) => v == null, + }); } + return appId; }), - filter((appId) => !!appId), + distinctUntilChanged(), + share(), ); this.anonymousAppId$ = anonymousAppIdState.state$.pipe( - tap(async (appId) => { + concatMap(async (appId) => { if (!appId) { - await anonymousAppIdState.update(() => Utils.newGuid()); + return await anonymousAppIdState.update(() => Utils.newGuid(), { + shouldUpdate: (v) => v == null, + }); } + return appId; }), - filter((appId) => !!appId), + distinctUntilChanged(), + share(), ); } diff --git a/libs/common/src/tools/generator/abstractions/randomizer.ts b/libs/common/src/tools/generator/abstractions/randomizer.ts new file mode 100644 index 00000000000..33222477593 --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/randomizer.ts @@ -0,0 +1,39 @@ +import { WordOptions } from "../word-options"; + +/** Entropy source for credential generation. */ +export interface Randomizer { + /** picks a random entry from a list. + * @param list random entry source. This must have at least one entry. + * @returns a promise that resolves with a random entry from the list. + */ + pick(list: Array): Promise; + + /** picks a random word from a list. + * @param list random entry source. This must have at least one entry. + * @param options customizes the output word + * @returns a promise that resolves with a random word from the list. + */ + pickWord(list: Array, options?: WordOptions): Promise; + + /** Shuffles a list of items + * @param list random entry source. This must have at least two entries. + * @param options.copy shuffles a copy of the input when this is true. + * Defaults to true. + * @returns a promise that resolves with the randomized list. + */ + shuffle(items: Array): Promise>; + + /** Generates a string containing random lowercase ASCII characters and numbers. + * @param length the number of characters to generate + * @returns a promise that resolves with the randomized string. + */ + chars(length: number): Promise; + + /** Selects an integer value from a range by randomly choosing it from + * a uniform distribution. + * @param min the minimum value in the range, inclusive. + * @param max the minimum value in the range, inclusive. + * @returns a promise that resolves with the randomized string. + */ + uniform(min: number, max: number): Promise; +} diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.ts b/libs/common/src/tools/generator/legacy-password-generation.service.ts index db4aa9d2a99..d69d4d2dc06 100644 --- a/libs/common/src/tools/generator/legacy-password-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-password-generation.service.ts @@ -40,11 +40,11 @@ import { import { GeneratedPasswordHistory, PasswordGenerationOptions, - PasswordGenerationService, PasswordGeneratorOptions, PasswordGeneratorPolicy, PasswordGeneratorStrategy, } from "./password"; +import { CryptoServiceRandomizer } from "./random"; type MappedOptions = { generator: GeneratorNavigation; @@ -60,17 +60,15 @@ export function legacyPasswordGenerationServiceFactory( accountService: AccountService, stateProvider: StateProvider, ): PasswordGenerationServiceAbstraction { - // FIXME: Once the password generation service is replaced with this service - // in the clients, factor out the deprecated service in its entirety. - const deprecatedService = new PasswordGenerationService(cryptoService, null, null); + const randomizer = new CryptoServiceRandomizer(cryptoService); const passwords = new DefaultGeneratorService( - new PasswordGeneratorStrategy(deprecatedService, stateProvider), + new PasswordGeneratorStrategy(randomizer, stateProvider), policyService, ); const passphrases = new DefaultGeneratorService( - new PassphraseGeneratorStrategy(deprecatedService, stateProvider), + new PassphraseGeneratorStrategy(randomizer, stateProvider), policyService, ); diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.ts b/libs/common/src/tools/generator/legacy-username-generation.service.ts index 61c19ee3144..aaa6bc2c806 100644 --- a/libs/common/src/tools/generator/legacy-username-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-username-generation.service.ts @@ -14,6 +14,7 @@ import { DefaultGeneratorService } from "./default-generator.service"; import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; import { GeneratorNavigation } from "./navigation/generator-navigation"; import { NoPolicy } from "./no-policy"; +import { CryptoServiceRandomizer } from "./random"; import { CatchallGeneratorStrategy, SubaddressGeneratorStrategy, @@ -37,7 +38,6 @@ import { } from "./username/options/forwarder-options"; import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; import { UsernameGeneratorOptions } from "./username/username-generation-options"; -import { UsernameGenerationService } from "./username/username-generation.service"; type MappedOptions = { generator: GeneratorNavigation; @@ -65,22 +65,20 @@ export function legacyUsernameGenerationServiceFactory( accountService: AccountService, stateProvider: StateProvider, ): UsernameGenerationServiceAbstraction { - // FIXME: Once the username generation service is replaced with this service - // in the clients, factor out the deprecated service in its entirety. - const deprecatedService = new UsernameGenerationService(cryptoService, null, null); + const randomizer = new CryptoServiceRandomizer(cryptoService); const effUsername = new DefaultGeneratorService( - new EffUsernameGeneratorStrategy(deprecatedService, stateProvider), + new EffUsernameGeneratorStrategy(randomizer, stateProvider), policyService, ); const subaddress = new DefaultGeneratorService( - new SubaddressGeneratorStrategy(deprecatedService, stateProvider), + new SubaddressGeneratorStrategy(randomizer, stateProvider), policyService, ); const catchall = new DefaultGeneratorService( - new CatchallGeneratorStrategy(deprecatedService, stateProvider), + new CatchallGeneratorStrategy(randomizer, stateProvider), policyService, ); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index 6ad1bd90dd4..429f81175a8 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -11,7 +11,7 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; -import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; +import { Randomizer } from "../abstractions/randomizer"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; @@ -65,8 +65,8 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, provider); + const randomizer = mock(); + const strategy = new PassphraseGeneratorStrategy(randomizer, provider); strategy.durableState(SomeUser); @@ -86,36 +86,14 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, null); + const randomizer = mock(); + const strategy = new PassphraseGeneratorStrategy(randomizer, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it("should call the legacy service with the given options", async () => { - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, null); - const options = { - type: "passphrase", - minNumberWords: 1, - capitalize: true, - includeNumber: true, - }; - - await strategy.generate(options); - - expect(legacy.generatePassphrase).toHaveBeenCalledWith(options); - }); - - it("should set the generation type to passphrase", async () => { - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, null); - - await strategy.generate({ type: "foo" } as any); - - expect(legacy.generatePassphrase).toHaveBeenCalledWith({ type: "passphrase" }); - }); + it.todo("should generate a password using the given options"); }); }); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index c7b5ff8b787..3ed6a1219ce 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -1,25 +1,20 @@ -import { BehaviorSubject, map, pipe } from "rxjs"; - import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; +import { EFFLongWordList } from "../../../platform/misc/wordlist"; import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; +import { Randomizer } from "../abstractions/randomizer"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; +import { Policies } from "../policies"; +import { mapPolicyToEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; import { PassphraseGenerationOptions, DefaultPassphraseGenerationOptions, } from "./passphrase-generation-options"; -import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; -import { - DisabledPassphraseGeneratorPolicy, - PassphraseGeneratorPolicy, - leastPrivilege, -} from "./passphrase-generator-policy"; +import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; -/** {@link GeneratorStrategy} */ +/** Generates passphrases composed of random words */ export class PassphraseGeneratorStrategy implements GeneratorStrategy { @@ -28,36 +23,48 @@ export class PassphraseGeneratorStrategy * @param stateProvider provides durable state */ constructor( - private legacy: PasswordGenerationServiceAbstraction, + private randomizer: Randomizer, private stateProvider: StateProvider, ) {} - /** {@link GeneratorStrategy.durableState} */ - durableState(id: UserId) { - return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS); - } - - /** Gets the default options. */ - defaults$(_: UserId) { - return new BehaviorSubject({ ...DefaultPassphraseGenerationOptions }).asObservable(); - } - - /** {@link GeneratorStrategy.policy} */ - get policy() { - return PolicyType.PasswordGenerator; - } - - /** {@link GeneratorStrategy.toEvaluator} */ + // configuration + durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions); + readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return pipe( - reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy), - distinctIfShallowMatch(), - map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)), - ); + return mapPolicyToEvaluator(Policies.Passphrase); } - /** {@link GeneratorStrategy.generate} */ - generate(options: PassphraseGenerationOptions): Promise { - return this.legacy.generatePassphrase({ ...options, type: "passphrase" }); + // algorithm + async generate(options: PassphraseGenerationOptions): Promise { + const o = { ...DefaultPassphraseGenerationOptions, ...options }; + if (o.numWords == null || o.numWords <= 2) { + o.numWords = DefaultPassphraseGenerationOptions.numWords; + } + if (o.capitalize == null) { + o.capitalize = false; + } + if (o.includeNumber == null) { + o.includeNumber = false; + } + + // select which word gets the number, if any + let luckyNumber = -1; + if (o.includeNumber) { + luckyNumber = await this.randomizer.uniform(0, o.numWords); + } + + // generate the passphrase + const wordList = new Array(o.numWords); + for (let i = 0; i < o.numWords; i++) { + const word = await this.randomizer.pickWord(EFFLongWordList, { + titleCase: o.capitalize, + number: i === luckyNumber, + }); + + wordList[i] = word; + } + + return wordList.join(o.wordSeparator); } } diff --git a/libs/common/src/tools/generator/password/index.ts b/libs/common/src/tools/generator/password/index.ts index e17ab8201c9..7e16a2c442a 100644 --- a/libs/common/src/tools/generator/password/index.ts +++ b/libs/common/src/tools/generator/password/index.ts @@ -7,5 +7,4 @@ export { PasswordGeneratorStrategy } from "./password-generator-strategy"; // legacy interfaces export { PasswordGeneratorOptions } from "./password-generator-options"; export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; -export { PasswordGenerationService } from "./password-generation.service"; export { GeneratedPasswordHistory } from "./generated-password-history"; diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts deleted file mode 100644 index e193b0fd33e..00000000000 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { from } from "rxjs"; - -import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "../../../admin-console/enums"; -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { StateService } from "../../../platform/abstractions/state.service"; -import { EFFLongWordList } from "../../../platform/misc/wordlist"; -import { EncString } from "../../../platform/models/domain/enc-string"; -import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; -import { PassphraseGeneratorOptionsEvaluator } from "../passphrase/passphrase-generator-options-evaluator"; - -import { GeneratedPasswordHistory } from "./generated-password-history"; -import { PasswordGeneratorOptions } from "./password-generator-options"; -import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; - -const DefaultOptions: PasswordGeneratorOptions = { - length: 14, - minLength: 5, - ambiguous: false, - number: true, - minNumber: 1, - uppercase: true, - minUppercase: 0, - lowercase: true, - minLowercase: 0, - special: false, - minSpecial: 0, - type: "password", - numWords: 3, - wordSeparator: "-", - capitalize: false, - includeNumber: false, -}; - -const DefaultPolicy = new PasswordGeneratorPolicyOptions(); - -const MaxPasswordsInHistory = 100; - -export class PasswordGenerationService implements PasswordGenerationServiceAbstraction { - constructor( - private cryptoService: CryptoService, - private policyService: PolicyService, - private stateService: StateService, - ) {} - - async generatePassword(options: PasswordGeneratorOptions): Promise { - if ((options.type ?? DefaultOptions.type) === "passphrase") { - return this.generatePassphrase({ ...DefaultOptions, ...options }); - } - - const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy); - const o = evaluator.sanitize({ ...DefaultOptions, ...options }); - - const positions: string[] = []; - if (o.lowercase && o.minLowercase > 0) { - for (let i = 0; i < o.minLowercase; i++) { - positions.push("l"); - } - } - if (o.uppercase && o.minUppercase > 0) { - for (let i = 0; i < o.minUppercase; i++) { - positions.push("u"); - } - } - if (o.number && o.minNumber > 0) { - for (let i = 0; i < o.minNumber; i++) { - positions.push("n"); - } - } - if (o.special && o.minSpecial > 0) { - for (let i = 0; i < o.minSpecial; i++) { - positions.push("s"); - } - } - while (positions.length < o.length) { - positions.push("a"); - } - - // shuffle - await this.shuffleArray(positions); - - // build out the char sets - let allCharSet = ""; - - let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz"; - if (o.ambiguous) { - lowercaseCharSet += "l"; - } - if (o.lowercase) { - allCharSet += lowercaseCharSet; - } - - let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - if (o.ambiguous) { - uppercaseCharSet += "IO"; - } - if (o.uppercase) { - allCharSet += uppercaseCharSet; - } - - let numberCharSet = "23456789"; - if (o.ambiguous) { - numberCharSet += "01"; - } - if (o.number) { - allCharSet += numberCharSet; - } - - const specialCharSet = "!@#$%^&*"; - if (o.special) { - allCharSet += specialCharSet; - } - - let password = ""; - for (let i = 0; i < o.length; i++) { - let positionChars: string; - switch (positions[i]) { - case "l": - positionChars = lowercaseCharSet; - break; - case "u": - positionChars = uppercaseCharSet; - break; - case "n": - positionChars = numberCharSet; - break; - case "s": - positionChars = specialCharSet; - break; - case "a": - positionChars = allCharSet; - break; - default: - break; - } - - const randomCharIndex = await this.cryptoService.randomNumber(0, positionChars.length - 1); - password += positionChars.charAt(randomCharIndex); - } - - return password; - } - - async generatePassphrase(options: PasswordGeneratorOptions): Promise { - const evaluator = new PassphraseGeneratorOptionsEvaluator(DefaultPolicy); - const o = evaluator.sanitize({ ...DefaultOptions, ...options }); - - if (o.numWords == null || o.numWords <= 2) { - o.numWords = DefaultOptions.numWords; - } - if (o.capitalize == null) { - o.capitalize = false; - } - if (o.includeNumber == null) { - o.includeNumber = false; - } - - const listLength = EFFLongWordList.length - 1; - const wordList = new Array(o.numWords); - for (let i = 0; i < o.numWords; i++) { - const wordIndex = await this.cryptoService.randomNumber(0, listLength); - if (o.capitalize) { - wordList[i] = this.capitalize(EFFLongWordList[wordIndex]); - } else { - wordList[i] = EFFLongWordList[wordIndex]; - } - } - - if (o.includeNumber) { - await this.appendRandomNumberToRandomWord(wordList); - } - return wordList.join(o.wordSeparator); - } - - getOptions$() { - return from(this.getOptions()); - } - - async getOptions(): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { - let options = await this.stateService.getPasswordGenerationOptions(); - if (options == null) { - options = Object.assign({}, DefaultOptions); - } else { - options = Object.assign({}, DefaultOptions, options); - } - await this.stateService.setPasswordGenerationOptions(options); - const enforcedOptions = await this.enforcePasswordGeneratorPoliciesOnOptions(options); - options = enforcedOptions[0]; - return [options, enforcedOptions[1]]; - } - - async enforcePasswordGeneratorPoliciesOnOptions( - options: PasswordGeneratorOptions, - ): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { - let policy = await this.getPasswordGeneratorPolicyOptions(); - policy = policy ?? new PasswordGeneratorPolicyOptions(); - - // Force default type if password/passphrase selected via policy - if (policy.defaultType === "password" || policy.defaultType === "passphrase") { - options.type = policy.defaultType; - } - - const evaluator = - options.type == "password" - ? new PasswordGeneratorOptionsEvaluator(policy) - : new PassphraseGeneratorOptionsEvaluator(policy); - - // Ensure the options to pass the current rules - const withPolicy = evaluator.applyPolicy(options); - const sanitized = evaluator.sanitize(withPolicy); - - // callers assume this function updates the options parameter - const result = Object.assign(options, sanitized); - return [result, policy]; - } - - async getPasswordGeneratorPolicyOptions(): Promise { - const policies = await this.policyService?.getAll(PolicyType.PasswordGenerator); - let enforcedOptions: PasswordGeneratorPolicyOptions = null; - - if (policies == null || policies.length === 0) { - return enforcedOptions; - } - - policies.forEach((currentPolicy) => { - if (!currentPolicy.enabled || currentPolicy.data == null) { - return; - } - - if (enforcedOptions == null) { - enforcedOptions = new PasswordGeneratorPolicyOptions(); - } - - // Password wins in multi-org collisions - if (currentPolicy.data.defaultType != null && enforcedOptions.defaultType !== "password") { - enforcedOptions.defaultType = currentPolicy.data.defaultType; - } - - if ( - currentPolicy.data.minLength != null && - currentPolicy.data.minLength > enforcedOptions.minLength - ) { - enforcedOptions.minLength = currentPolicy.data.minLength; - } - - if (currentPolicy.data.useUpper) { - enforcedOptions.useUppercase = true; - } - - if (currentPolicy.data.useLower) { - enforcedOptions.useLowercase = true; - } - - if (currentPolicy.data.useNumbers) { - enforcedOptions.useNumbers = true; - } - - if ( - currentPolicy.data.minNumbers != null && - currentPolicy.data.minNumbers > enforcedOptions.numberCount - ) { - enforcedOptions.numberCount = currentPolicy.data.minNumbers; - } - - if (currentPolicy.data.useSpecial) { - enforcedOptions.useSpecial = true; - } - - if ( - currentPolicy.data.minSpecial != null && - currentPolicy.data.minSpecial > enforcedOptions.specialCount - ) { - enforcedOptions.specialCount = currentPolicy.data.minSpecial; - } - - if ( - currentPolicy.data.minNumberWords != null && - currentPolicy.data.minNumberWords > enforcedOptions.minNumberWords - ) { - enforcedOptions.minNumberWords = currentPolicy.data.minNumberWords; - } - - if (currentPolicy.data.capitalize) { - enforcedOptions.capitalize = true; - } - - if (currentPolicy.data.includeNumber) { - enforcedOptions.includeNumber = true; - } - }); - - return enforcedOptions; - } - - async saveOptions(options: PasswordGeneratorOptions) { - await this.stateService.setPasswordGenerationOptions(options); - } - - async getHistory(): Promise { - const hasKey = await this.cryptoService.hasUserKey(); - if (!hasKey) { - return new Array(); - } - - if ((await this.stateService.getDecryptedPasswordGenerationHistory()) == null) { - const encrypted = await this.stateService.getEncryptedPasswordGenerationHistory(); - const decrypted = await this.decryptHistory(encrypted); - await this.stateService.setDecryptedPasswordGenerationHistory(decrypted); - } - - const passwordGenerationHistory = - await this.stateService.getDecryptedPasswordGenerationHistory(); - return passwordGenerationHistory != null - ? passwordGenerationHistory - : new Array(); - } - - async addHistory(password: string): Promise { - // Cannot add new history if no key is available - const hasKey = await this.cryptoService.hasUserKey(); - if (!hasKey) { - return; - } - - const currentHistory = await this.getHistory(); - - // Prevent duplicates - if (this.matchesPrevious(password, currentHistory)) { - return; - } - - currentHistory.unshift(new GeneratedPasswordHistory(password, Date.now())); - - // Remove old items. - if (currentHistory.length > MaxPasswordsInHistory) { - currentHistory.pop(); - } - - const newHistory = await this.encryptHistory(currentHistory); - await this.stateService.setDecryptedPasswordGenerationHistory(currentHistory); - return await this.stateService.setEncryptedPasswordGenerationHistory(newHistory); - } - - async clear(userId?: string): Promise { - await this.stateService.setEncryptedPasswordGenerationHistory(null, { userId: userId }); - await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId }); - return []; - } - - private capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - - private async appendRandomNumberToRandomWord(wordList: string[]) { - if (wordList == null || wordList.length <= 0) { - return; - } - const index = await this.cryptoService.randomNumber(0, wordList.length - 1); - const num = await this.cryptoService.randomNumber(0, 9); - wordList[index] = wordList[index] + num; - } - - private async encryptHistory( - history: GeneratedPasswordHistory[], - ): Promise { - if (history == null || history.length === 0) { - return Promise.resolve([]); - } - - const promises = history.map(async (item) => { - const encrypted = await this.cryptoService.encrypt(item.password); - return new GeneratedPasswordHistory(encrypted.encryptedString, item.date); - }); - - return await Promise.all(promises); - } - - private async decryptHistory( - history: GeneratedPasswordHistory[], - ): Promise { - if (history == null || history.length === 0) { - return Promise.resolve([]); - } - - const promises = history.map(async (item) => { - const decrypted = await this.cryptoService.decryptToUtf8(new EncString(item.password)); - return new GeneratedPasswordHistory(decrypted, item.date); - }); - - return await Promise.all(promises); - } - - private matchesPrevious(password: string, history: GeneratedPasswordHistory[]): boolean { - if (history == null || history.length === 0) { - return false; - } - - return history[history.length - 1].password === password; - } - - // ref: https://stackoverflow.com/a/12646864/1090359 - private async shuffleArray(array: string[]) { - for (let i = array.length - 1; i > 0; i--) { - const j = await this.cryptoService.randomNumber(0, i); - [array[i], array[j]] = [array[j], array[i]]; - } - } -} diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index a7509e8b43e..668dd818e25 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -12,13 +12,13 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { Randomizer } from "../abstractions/randomizer"; import { PASSWORD_SETTINGS } from "../key-definitions"; import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; import { DefaultPasswordGenerationOptions, - PasswordGenerationServiceAbstraction, PasswordGeneratorOptionsEvaluator, PasswordGeneratorStrategy, } from "."; @@ -74,8 +74,8 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, provider); + const randomizer = mock(); + const strategy = new PasswordGeneratorStrategy(randomizer, provider); strategy.durableState(SomeUser); @@ -95,40 +95,14 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, null); + const randomizer = mock(); + const strategy = new PasswordGeneratorStrategy(randomizer, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it("should call the legacy service with the given options", async () => { - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, null); - const options = { - type: "password", - minLength: 1, - useUppercase: true, - useLowercase: true, - useNumbers: true, - numberCount: 1, - useSpecial: true, - specialCount: 1, - }; - - await strategy.generate(options); - - expect(legacy.generatePassword).toHaveBeenCalledWith(options); - }); - - it("should set the generation type to password", async () => { - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, null); - - await strategy.generate({ type: "foo" } as any); - - expect(legacy.generatePassword).toHaveBeenCalledWith({ type: "password" }); - }); + it.todo("should generate a password using the given options"); }); }); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index 23828d7b59d..075c331e066 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -1,25 +1,19 @@ -import { BehaviorSubject, map, pipe } from "rxjs"; - import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; +import { Randomizer } from "../abstractions/randomizer"; import { PASSWORD_SETTINGS } from "../key-definitions"; -import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; +import { Policies } from "../policies"; +import { mapPolicyToEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; import { DefaultPasswordGenerationOptions, PasswordGenerationOptions, } from "./password-generation-options"; -import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; -import { - DisabledPasswordGeneratorPolicy, - PasswordGeneratorPolicy, - leastPrivilege, -} from "./password-generator-policy"; +import { PasswordGeneratorPolicy } from "./password-generator-policy"; -/** {@link GeneratorStrategy} */ +/** Generates passwords composed of random characters */ export class PasswordGeneratorStrategy implements GeneratorStrategy { @@ -27,36 +21,108 @@ export class PasswordGeneratorStrategy * @param legacy generates the password */ constructor( - private legacy: PasswordGenerationServiceAbstraction, + private randomizer: Randomizer, private stateProvider: StateProvider, ) {} - /** {@link GeneratorStrategy.durableState} */ - durableState(id: UserId) { - return this.stateProvider.getUser(id, PASSWORD_SETTINGS); - } - - /** Gets the default options. */ - defaults$(_: UserId) { - return new BehaviorSubject({ ...DefaultPasswordGenerationOptions }).asObservable(); - } - - /** {@link GeneratorStrategy.policy} */ - get policy() { - return PolicyType.PasswordGenerator; - } - - /** {@link GeneratorStrategy.toEvaluator} */ + // configuration + durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions); + readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return pipe( - reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy), - distinctIfShallowMatch(), - map((policy) => new PasswordGeneratorOptionsEvaluator(policy)), - ); + return mapPolicyToEvaluator(Policies.Password); } - /** {@link GeneratorStrategy.generate} */ - generate(options: PasswordGenerationOptions): Promise { - return this.legacy.generatePassword({ ...options, type: "password" }); + // algorithm + async generate(options: PasswordGenerationOptions): Promise { + const o = { ...DefaultPasswordGenerationOptions, ...options }; + let positions: string[] = []; + if (o.lowercase && o.minLowercase > 0) { + for (let i = 0; i < o.minLowercase; i++) { + positions.push("l"); + } + } + if (o.uppercase && o.minUppercase > 0) { + for (let i = 0; i < o.minUppercase; i++) { + positions.push("u"); + } + } + if (o.number && o.minNumber > 0) { + for (let i = 0; i < o.minNumber; i++) { + positions.push("n"); + } + } + if (o.special && o.minSpecial > 0) { + for (let i = 0; i < o.minSpecial; i++) { + positions.push("s"); + } + } + while (positions.length < o.length) { + positions.push("a"); + } + + // shuffle + positions = await this.randomizer.shuffle(positions); + + // build out the char sets + let allCharSet = ""; + + let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz"; + if (o.ambiguous) { + lowercaseCharSet += "l"; + } + if (o.lowercase) { + allCharSet += lowercaseCharSet; + } + + let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + if (o.ambiguous) { + uppercaseCharSet += "IO"; + } + if (o.uppercase) { + allCharSet += uppercaseCharSet; + } + + let numberCharSet = "23456789"; + if (o.ambiguous) { + numberCharSet += "01"; + } + if (o.number) { + allCharSet += numberCharSet; + } + + const specialCharSet = "!@#$%^&*"; + if (o.special) { + allCharSet += specialCharSet; + } + + let password = ""; + for (let i = 0; i < o.length; i++) { + let positionChars: string; + switch (positions[i]) { + case "l": + positionChars = lowercaseCharSet; + break; + case "u": + positionChars = uppercaseCharSet; + break; + case "n": + positionChars = numberCharSet; + break; + case "s": + positionChars = specialCharSet; + break; + case "a": + positionChars = allCharSet; + break; + default: + break; + } + + const randomCharIndex = await this.randomizer.uniform(0, positionChars.length - 1); + password += positionChars.charAt(randomCharIndex); + } + + return password; } } diff --git a/libs/common/src/tools/generator/policies.ts b/libs/common/src/tools/generator/policies.ts new file mode 100644 index 00000000000..27521f0eebe --- /dev/null +++ b/libs/common/src/tools/generator/policies.ts @@ -0,0 +1,48 @@ +import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorPolicy } from "./passphrase"; +import { + DisabledPassphraseGeneratorPolicy, + leastPrivilege as passphraseLeastPrivilege, +} from "./passphrase/passphrase-generator-policy"; +import { PasswordGeneratorOptionsEvaluator, PasswordGeneratorPolicy } from "./password"; +import { + DisabledPasswordGeneratorPolicy, + leastPrivilege as passwordLeastPrivilege, +} from "./password/password-generator-policy"; + +/** Determines how to construct a password generator policy */ +export type PolicyConfiguration = { + /** The value of the policy when it is not in effect. */ + disabledValue: Policy; + + /** Combines multiple policies set by the administrative console into + * a single policy. + */ + combine: (acc: Policy, policy: AdminPolicy) => Policy; + + /** Converts policy service data into an actionable policy. + */ + createEvaluator: (policy: Policy) => Evaluator; +}; + +const PASSPHRASE = Object.freeze({ + disabledValue: DisabledPassphraseGeneratorPolicy, + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), +} as PolicyConfiguration); + +const PASSWORD = Object.freeze({ + disabledValue: DisabledPasswordGeneratorPolicy, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), +} as PolicyConfiguration); + +/** Policy configurations */ +export const Policies = Object.freeze({ + /** Passphrase policy configuration */ + Passphrase: PASSPHRASE, + + /** Passphrase policy configuration */ + Password: PASSWORD, +}); diff --git a/libs/common/src/tools/generator/random.ts b/libs/common/src/tools/generator/random.ts new file mode 100644 index 00000000000..255a5df7a79 --- /dev/null +++ b/libs/common/src/tools/generator/random.ts @@ -0,0 +1,62 @@ +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +import { Randomizer } from "./abstractions/randomizer"; +import { WordOptions } from "./word-options"; + +/** A randomizer backed by a CryptoService. */ +export class CryptoServiceRandomizer implements Randomizer { + constructor(private crypto: CryptoService) {} + + async pick(list: Array) { + const index = await this.uniform(0, list.length - 1); + return list[index]; + } + + async pickWord(list: Array, options?: WordOptions) { + let word = await this.pick(list); + + if (options?.titleCase ?? false) { + word = word.charAt(0).toUpperCase() + word.slice(1); + } + + if (options?.number ?? false) { + const num = await this.crypto.randomNumber(1, 9999); + word = word + this.zeroPad(num.toString(), 4); + } + + return word; + } + + // ref: https://stackoverflow.com/a/12646864/1090359 + async shuffle(items: Array, options?: { copy?: boolean }) { + const shuffled = options?.copy ?? true ? [...items] : items; + + for (let i = items.length - 1; i > 0; i--) { + const j = await this.uniform(0, i); + [items[i], items[j]] = [items[j], items[i]]; + } + + return shuffled; + } + + async chars(length: number) { + let str = ""; + const charSet = "abcdefghijklmnopqrstuvwxyz1234567890"; + for (let i = 0; i < length; i++) { + const randomCharIndex = await this.uniform(0, charSet.length - 1); + str += charSet.charAt(randomCharIndex); + } + return str; + } + + async uniform(min: number, max: number) { + return this.crypto.randomNumber(min, max); + } + + // ref: https://stackoverflow.com/a/10073788 + private zeroPad(number: string, width: number) { + return number.length >= width + ? number + : new Array(width - number.length + 1).join("0") + number; + } +} diff --git a/libs/common/src/tools/generator/rx-operators.ts b/libs/common/src/tools/generator/rx-operators.ts index 6524ef79941..47233fa7785 100644 --- a/libs/common/src/tools/generator/rx-operators.ts +++ b/libs/common/src/tools/generator/rx-operators.ts @@ -1,4 +1,7 @@ -import { distinctUntilChanged, map, OperatorFunction } from "rxjs"; +import { distinctUntilChanged, map, OperatorFunction, pipe } from "rxjs"; + +import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; +import { PolicyConfiguration } from "./policies"; /** * An observable operator that reduces an emitted collection to a single object, @@ -36,3 +39,23 @@ export function distinctIfShallowMatch(): OperatorFunction { return isDistinct; }); } + +/** Maps an administrative console policy to a policy evaluator using the provided configuration. + * @param configuration the configuration that constructs the evaluator. + */ +export function mapPolicyToEvaluator( + configuration: PolicyConfiguration, +) { + return pipe( + reduceCollection(configuration.combine, configuration.disabledValue), + distinctIfShallowMatch(), + map(configuration.createEvaluator), + ); +} + +/** Constructs a method that maps a policy to the default (no-op) policy. */ +export function newDefaultEvaluator() { + return () => { + return pipe(map((_) => new DefaultPolicyEvaluator())); + }; +} diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index 30f11b1e89b..45e87160817 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { Randomizer } from "../abstractions/randomizer"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { CATCHALL_SETTINGS } from "../key-definitions"; -import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; +import { DefaultCatchallOptions } from "./catchall-generator-options"; -import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; +import { CatchallGeneratorStrategy } from "."; const SomeUser = "some user" as UserId; const SomePolicy = mock({ @@ -40,8 +41,8 @@ describe("Email subaddress list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const legacy = mock(); - const strategy = new CatchallGeneratorStrategy(legacy, provider); + const randomizer = mock(); + const strategy = new CatchallGeneratorStrategy(randomizer, provider); strategy.durableState(SomeUser); @@ -61,26 +62,14 @@ describe("Email subaddress list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const legacy = mock(); - const strategy = new CatchallGeneratorStrategy(legacy, null); + const randomizer = mock(); + const strategy = new CatchallGeneratorStrategy(randomizer, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it("should call the legacy service with the given options", async () => { - const legacy = mock(); - const strategy = new CatchallGeneratorStrategy(legacy, null); - const options = { - catchallType: "website-name", - catchallDomain: "example.com", - website: "foo.com", - } as CatchallGenerationOptions; - - await strategy.generate(options); - - expect(legacy.generateCatchall).toHaveBeenCalledWith(options); - }); + it.todo("generate catchall email addresses"); }); }); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index ee86fd9fd66..fb015a596ff 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -1,13 +1,11 @@ -import { BehaviorSubject, map, pipe } from "rxjs"; - import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; -import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; +import { Randomizer } from "../abstractions/randomizer"; import { CATCHALL_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; +import { newDefaultEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; @@ -19,34 +17,34 @@ export class CatchallGeneratorStrategy * @param usernameService generates a catchall address for a domain */ constructor( - private usernameService: UsernameGenerationServiceAbstraction, + private random: Randomizer, private stateProvider: StateProvider, + private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions, ) {} - /** {@link GeneratorStrategy.durableState} */ - durableState(id: UserId) { - return this.stateProvider.getUser(id, CATCHALL_SETTINGS); - } + // configuration + durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; - /** {@link GeneratorStrategy.defaults$} */ - defaults$(userId: UserId) { - return new BehaviorSubject({ ...DefaultCatchallOptions }).asObservable(); - } + // algorithm + async generate(options: CatchallGenerationOptions) { + const o = Object.assign({}, DefaultCatchallOptions, options); - /** {@link GeneratorStrategy.policy} */ - get policy() { - // Uses password generator since there aren't policies - // specific to usernames. - return PolicyType.PasswordGenerator; - } + if (o.catchallDomain == null || o.catchallDomain === "") { + return null; + } + if (o.catchallType == null) { + o.catchallType = "random"; + } - /** {@link GeneratorStrategy.toEvaluator} */ - toEvaluator() { - return pipe(map((_) => new DefaultPolicyEvaluator())); - } - - /** {@link GeneratorStrategy.generate} */ - generate(options: CatchallGenerationOptions) { - return this.usernameService.generateCatchall(options); + let startString = ""; + if (o.catchallType === "random") { + startString = await this.random.chars(8); + } else if (o.catchallType === "website-name") { + startString = o.website; + } + return startString + "@" + o.catchallDomain; } } diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 76e51f609cc..128b69e6734 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { Randomizer } from "../abstractions/randomizer"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { DefaultEffUsernameOptions } from "./eff-username-generator-options"; -import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; +import { EffUsernameGeneratorStrategy } from "."; const SomeUser = "some user" as UserId; const SomePolicy = mock({ @@ -40,8 +41,8 @@ describe("EFF long word list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const legacy = mock(); - const strategy = new EffUsernameGeneratorStrategy(legacy, provider); + const randomizer = mock(); + const strategy = new EffUsernameGeneratorStrategy(randomizer, provider); strategy.durableState(SomeUser); @@ -61,26 +62,14 @@ describe("EFF long word list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const legacy = mock(); - const strategy = new EffUsernameGeneratorStrategy(legacy, null); + const randomizer = mock(); + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it("should call the legacy service with the given options", async () => { - const legacy = mock(); - const strategy = new EffUsernameGeneratorStrategy(legacy, null); - const options = { - wordCapitalize: false, - wordIncludeNumber: false, - website: null as string, - }; - - await strategy.generate(options); - - expect(legacy.generateWord).toHaveBeenCalledWith(options); - }); + it.todo("generate username tests"); }); }); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index 70d1f854205..abd8e6b226c 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -1,13 +1,13 @@ -import { BehaviorSubject, map, pipe } from "rxjs"; +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; -import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; +import { Randomizer } from "../abstractions/randomizer"; import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; +import { newDefaultEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; import { DefaultEffUsernameOptions, @@ -22,34 +22,23 @@ export class EffUsernameGeneratorStrategy * @param usernameService generates a username from EFF word list */ constructor( - private usernameService: UsernameGenerationServiceAbstraction, + private random: Randomizer, private stateProvider: StateProvider, + private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions, ) {} - /** {@link GeneratorStrategy.durableState} */ - durableState(id: UserId) { - return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS); - } + // configuration + durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; - /** {@link GeneratorStrategy.defaults$} */ - defaults$(userId: UserId) { - return new BehaviorSubject({ ...DefaultEffUsernameOptions }).asObservable(); - } - - /** {@link GeneratorStrategy.policy} */ - get policy() { - // Uses password generator since there aren't policies - // specific to usernames. - return PolicyType.PasswordGenerator; - } - - /** {@link GeneratorStrategy.toEvaluator} */ - toEvaluator() { - return pipe(map((_) => new DefaultPolicyEvaluator())); - } - - /** {@link GeneratorStrategy.generate} */ - generate(options: EffUsernameGenerationOptions) { - return this.usernameService.generateWord(options); + // algorithm + async generate(options: EffUsernameGenerationOptions) { + const word = await this.random.pickWord(EFFLongWordList, { + titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize, + number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber, + }); + return word; } } diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index 7c1b4b91913..e78b432bfb6 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -25,7 +25,7 @@ class TestForwarder extends ForwarderGeneratorStrategy { keyService: CryptoService, stateProvider: StateProvider, ) { - super(encryptService, keyService, stateProvider); + super(encryptService, keyService, stateProvider, { website: null, token: "" }); } get key() { diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index 28ebcba4fd9..4655a3fb72a 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -1,4 +1,4 @@ -import { Observable, map, pipe } from "rxjs"; +import { map } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; @@ -13,8 +13,9 @@ import { SecretKeyDefinition } from "../../state/secret-key-definition"; import { SecretState } from "../../state/secret-state"; import { UserKeyEncryptor } from "../../state/user-key-encryptor"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { NoPolicy } from "../no-policy"; +import { newDefaultEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedByUserId } from "../util"; import { ApiOptions } from "./options/forwarder-options"; @@ -33,29 +34,25 @@ export abstract class ForwarderGeneratorStrategy< private readonly encryptService: EncryptService, private readonly keyService: CryptoService, private stateProvider: StateProvider, + private readonly defaultOptions: Options, ) { super(); - // Uses password generator since there aren't policies - // specific to usernames. - this.policy = PolicyType.PasswordGenerator; } - private durableStates = new Map>(); + /** configures forwarder secret storage */ + protected abstract readonly key: UserKeyDefinition; - /** {@link GeneratorStrategy.durableState} */ - durableState = (userId: UserId) => { - let state = this.durableStates.get(userId); + /** configures forwarder import buffer */ + protected abstract readonly rolloverKey: BufferedKeyDefinition; - if (!state) { - state = this.createState(userId); + // configuration + readonly policy = PolicyType.PasswordGenerator; + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + durableState = sharedByUserId((userId) => this.getUserSecrets(userId)); - this.durableStates.set(userId, state); - } - - return state; - }; - - private createState(userId: UserId): SingleUserState { + // per-user encrypted state + private getUserSecrets(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); @@ -92,18 +89,4 @@ export abstract class ForwarderGeneratorStrategy< return rolloverState; } - - /** Gets the default options. */ - abstract defaults$: (userId: UserId) => Observable; - - /** Determine where forwarder configuration is stored */ - protected abstract readonly key: UserKeyDefinition; - - /** Determine where forwarder rollover configuration is stored */ - protected abstract readonly rolloverKey: BufferedKeyDefinition; - - /** {@link GeneratorStrategy.toEvaluator} */ - toEvaluator = () => { - return pipe(map((_) => new DefaultPolicyEvaluator())); - }; } diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts index 12121749519..ecf60da195c 100644 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.ts +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.ts @@ -1,11 +1,8 @@ -import { BehaviorSubject } from "rxjs"; - import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; -import { UserId } from "../../../../types/guid"; import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; @@ -36,25 +33,14 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy< keyService: CryptoService, stateProvider: StateProvider, ) { - super(encryptService, keyService, stateProvider); + super(encryptService, keyService, stateProvider, DefaultAddyIoOptions); } - /** {@link ForwarderGeneratorStrategy.key} */ - get key() { - return ADDY_IO_FORWARDER; - } + // configuration + readonly key = ADDY_IO_FORWARDER; + readonly rolloverKey = ADDY_IO_BUFFER; - /** {@link ForwarderGeneratorStrategy.rolloverKey} */ - get rolloverKey() { - return ADDY_IO_BUFFER; - } - - /** {@link ForwarderGeneratorStrategy.defaults$} */ - defaults$ = (userId: UserId) => { - return new BehaviorSubject({ ...DefaultAddyIoOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts index 4a9040d74a9..492105dfdfd 100644 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts +++ b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts @@ -1,11 +1,8 @@ -import { BehaviorSubject } from "rxjs"; - import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; -import { UserId } from "../../../../types/guid"; import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; @@ -32,25 +29,14 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy keyService: CryptoService, stateProvider: StateProvider, ) { - super(encryptService, keyService, stateProvider); + super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions); } - /** {@link ForwarderGeneratorStrategy.key} */ - get key() { - return DUCK_DUCK_GO_FORWARDER; - } + // configuration + readonly key = DUCK_DUCK_GO_FORWARDER; + readonly rolloverKey = DUCK_DUCK_GO_BUFFER; - /** {@link ForwarderGeneratorStrategy.rolloverKey} */ - get rolloverKey() { - return DUCK_DUCK_GO_BUFFER; - } - - /** {@link ForwarderGeneratorStrategy.defaults$} */ - defaults$ = (userId: UserId) => { - return new BehaviorSubject({ ...DefaultDuckDuckGoOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: ApiOptions): Promise => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts index 0236e658fbe..0c4e0e2cfd2 100644 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.ts +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.ts @@ -1,11 +1,8 @@ -import { BehaviorSubject } from "rxjs"; - import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; -import { UserId } from "../../../../types/guid"; import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; @@ -34,25 +31,14 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { - return new BehaviorSubject({ ...DefaultFastmailOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.rolloverKey} */ - get rolloverKey() { - return FASTMAIL_BUFFER; - } - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: ApiOptions & EmailPrefixOptions) => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); @@ -76,7 +62,7 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { - return new BehaviorSubject({ ...DefaultFirefoxRelayOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: ApiOptions) => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.ts index af654d3917e..20dfe012915 100644 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.ts +++ b/libs/common/src/tools/generator/username/forwarders/forward-email.ts @@ -1,12 +1,9 @@ -import { BehaviorSubject } from "rxjs"; - import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { Utils } from "../../../../platform/misc/utils"; import { StateProvider } from "../../../../platform/state"; -import { UserId } from "../../../../types/guid"; import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; @@ -36,25 +33,14 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< keyService: CryptoService, stateProvider: StateProvider, ) { - super(encryptService, keyService, stateProvider); + super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions); } - /** {@link ForwarderGeneratorStrategy.key} */ - get key() { - return FORWARD_EMAIL_FORWARDER; - } + // configuration + readonly key = FORWARD_EMAIL_FORWARDER; + readonly rolloverKey = FORWARD_EMAIL_BUFFER; - /** {@link ForwarderGeneratorStrategy.defaults$} */ - defaults$ = (userId: UserId) => { - return new BehaviorSubject({ ...DefaultForwardEmailOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.rolloverKey} */ - get rolloverKey() { - return FORWARD_EMAIL_BUFFER; - } - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: ApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name); diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.ts index ee91a411451..593c7346419 100644 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.ts +++ b/libs/common/src/tools/generator/username/forwarders/simple-login.ts @@ -1,11 +1,8 @@ -import { BehaviorSubject } from "rxjs"; - import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; -import { UserId } from "../../../../types/guid"; import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; @@ -33,25 +30,14 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { - return new BehaviorSubject({ ...DefaultSimpleLoginOptions }); - }; - - /** {@link ForwarderGeneratorStrategy.generate} */ + // request generate = async (options: SelfHostedApiOptions) => { if (!options.token || options.token === "") { const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); diff --git a/libs/common/src/tools/generator/username/index.ts b/libs/common/src/tools/generator/username/index.ts index 7c5ec45f748..a9d8e676086 100644 --- a/libs/common/src/tools/generator/username/index.ts +++ b/libs/common/src/tools/generator/username/index.ts @@ -3,4 +3,3 @@ export { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; export { UsernameGeneratorOptions } from "./username-generation-options"; export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; -export { UsernameGenerationService } from "./username-generation.service"; diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index b5ac9c4cf9c..ba1d5aa2b8f 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -7,15 +7,13 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { Randomizer } from "../abstractions/randomizer"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { SUBADDRESS_SETTINGS } from "../key-definitions"; -import { - DefaultSubaddressOptions, - SubaddressGenerationOptions, -} from "./subaddress-generator-options"; +import { DefaultSubaddressOptions } from "./subaddress-generator-options"; -import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; +import { SubaddressGeneratorStrategy } from "."; const SomeUser = "some user" as UserId; const SomePolicy = mock({ @@ -43,8 +41,8 @@ describe("Email subaddress list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const legacy = mock(); - const strategy = new SubaddressGeneratorStrategy(legacy, provider); + const randomizer = mock(); + const strategy = new SubaddressGeneratorStrategy(randomizer, provider); strategy.durableState(SomeUser); @@ -64,26 +62,14 @@ describe("Email subaddress list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const legacy = mock(); - const strategy = new SubaddressGeneratorStrategy(legacy, null); + const randomizer = mock(); + const strategy = new SubaddressGeneratorStrategy(randomizer, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it("should call the legacy service with the given options", async () => { - const legacy = mock(); - const strategy = new SubaddressGeneratorStrategy(legacy, null); - const options = { - subaddressType: "website-name", - subaddressEmail: "someone@example.com", - website: "foo.com", - } as SubaddressGenerationOptions; - - await strategy.generate(options); - - expect(legacy.generateSubaddress).toHaveBeenCalledWith(options); - }); + it.todo("generate email subaddress tests"); }); }); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 6106d6d476f..e44735c2131 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -1,13 +1,11 @@ -import { BehaviorSubject, map, pipe } from "rxjs"; - import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; -import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; +import { Randomizer } from "../abstractions/randomizer"; import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; +import { newDefaultEvaluator } from "../rx-operators"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; import { DefaultSubaddressOptions, @@ -26,34 +24,42 @@ export class SubaddressGeneratorStrategy * @param usernameService generates an email subaddress from an email address */ constructor( - private usernameService: UsernameGenerationServiceAbstraction, + private random: Randomizer, private stateProvider: StateProvider, + private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions, ) {} - /** {@link GeneratorStrategy.durableState} */ - durableState(id: UserId) { - return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS); - } + // configuration + durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; - /** {@link GeneratorStrategy.defaults$} */ - defaults$(userId: UserId) { - return new BehaviorSubject({ ...DefaultSubaddressOptions }).asObservable(); - } + // algorithm + async generate(options: SubaddressGenerationOptions) { + const o = Object.assign({}, DefaultSubaddressOptions, options); - /** {@link GeneratorStrategy.policy} */ - get policy() { - // Uses password generator since there aren't policies - // specific to usernames. - return PolicyType.PasswordGenerator; - } + const subaddressEmail = o.subaddressEmail; + if (subaddressEmail == null || subaddressEmail.length < 3) { + return o.subaddressEmail; + } + const atIndex = subaddressEmail.indexOf("@"); + if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { + return subaddressEmail; + } + if (o.subaddressType == null) { + o.subaddressType = "random"; + } - /** {@link GeneratorStrategy.toEvaluator} */ - toEvaluator() { - return pipe(map((_) => new DefaultPolicyEvaluator())); - } + const emailBeginning = subaddressEmail.substr(0, atIndex); + const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); - /** {@link GeneratorStrategy.generate} */ - generate(options: SubaddressGenerationOptions) { - return this.usernameService.generateSubaddress(options); + let subaddressString = ""; + if (o.subaddressType === "random") { + subaddressString = await this.random.chars(8); + } else if (o.subaddressType === "website-name") { + subaddressString = o.website; + } + return emailBeginning + "+" + subaddressString + "@" + emailEnding; } } diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts deleted file mode 100644 index e659aacb51f..00000000000 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { from } from "rxjs"; - -import { ApiService } from "../../../abstractions/api.service"; -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { StateService } from "../../../platform/abstractions/state.service"; -import { EFFLongWordList } from "../../../platform/misc/wordlist"; -import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; - -import { - AnonAddyForwarder, - DuckDuckGoForwarder, - FastmailForwarder, - FirefoxRelayForwarder, - ForwardEmailForwarder, - Forwarder, - ForwarderOptions, - SimpleLoginForwarder, -} from "./email-forwarders"; -import { UsernameGeneratorOptions } from "./username-generation-options"; - -const DefaultOptions: UsernameGeneratorOptions = { - type: "word", - website: null, - wordCapitalize: true, - wordIncludeNumber: true, - subaddressType: "random", - catchallType: "random", - forwardedService: "", - forwardedAnonAddyDomain: "anonaddy.me", - forwardedAnonAddyBaseUrl: "https://app.addy.io", - forwardedForwardEmailDomain: "hideaddress.net", - forwardedSimpleLoginBaseUrl: "https://app.simplelogin.io", -}; - -export class UsernameGenerationService implements UsernameGenerationServiceAbstraction { - constructor( - private cryptoService: CryptoService, - private stateService: StateService, - private apiService: ApiService, - ) {} - - generateUsername(options: UsernameGeneratorOptions): Promise { - if (options.type === "catchall") { - return this.generateCatchall(options); - } else if (options.type === "subaddress") { - return this.generateSubaddress(options); - } else if (options.type === "forwarded") { - return this.generateForwarded(options); - } else { - return this.generateWord(options); - } - } - - async generateWord(options: UsernameGeneratorOptions): Promise { - const o = Object.assign({}, DefaultOptions, options); - - if (o.wordCapitalize == null) { - o.wordCapitalize = true; - } - if (o.wordIncludeNumber == null) { - o.wordIncludeNumber = true; - } - - const wordIndex = await this.cryptoService.randomNumber(0, EFFLongWordList.length - 1); - let word = EFFLongWordList[wordIndex]; - if (o.wordCapitalize) { - word = word.charAt(0).toUpperCase() + word.slice(1); - } - if (o.wordIncludeNumber) { - const num = await this.cryptoService.randomNumber(1, 9999); - word = word + this.zeroPad(num.toString(), 4); - } - return word; - } - - async generateSubaddress(options: UsernameGeneratorOptions): Promise { - const o = Object.assign({}, DefaultOptions, options); - - const subaddressEmail = o.subaddressEmail; - if (subaddressEmail == null || subaddressEmail.length < 3) { - return o.subaddressEmail; - } - const atIndex = subaddressEmail.indexOf("@"); - if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { - return subaddressEmail; - } - if (o.subaddressType == null) { - o.subaddressType = "random"; - } - - const emailBeginning = subaddressEmail.substr(0, atIndex); - const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); - - let subaddressString = ""; - if (o.subaddressType === "random") { - subaddressString = await this.randomString(8); - } else if (o.subaddressType === "website-name") { - subaddressString = o.website; - } - return emailBeginning + "+" + subaddressString + "@" + emailEnding; - } - - async generateCatchall(options: UsernameGeneratorOptions): Promise { - const o = Object.assign({}, DefaultOptions, options); - - if (o.catchallDomain == null || o.catchallDomain === "") { - return null; - } - if (o.catchallType == null) { - o.catchallType = "random"; - } - - let startString = ""; - if (o.catchallType === "random") { - startString = await this.randomString(8); - } else if (o.catchallType === "website-name") { - startString = o.website; - } - return startString + "@" + o.catchallDomain; - } - - async generateForwarded(options: UsernameGeneratorOptions): Promise { - const o = Object.assign({}, DefaultOptions, options); - - if (o.forwardedService == null) { - return null; - } - - let forwarder: Forwarder = null; - const forwarderOptions = new ForwarderOptions(); - forwarderOptions.website = o.website; - if (o.forwardedService === "simplelogin") { - forwarder = new SimpleLoginForwarder(); - forwarderOptions.apiKey = o.forwardedSimpleLoginApiKey; - forwarderOptions.simplelogin.baseUrl = o.forwardedSimpleLoginBaseUrl; - } else if (o.forwardedService === "anonaddy") { - forwarder = new AnonAddyForwarder(); - forwarderOptions.apiKey = o.forwardedAnonAddyApiToken; - forwarderOptions.anonaddy.domain = o.forwardedAnonAddyDomain; - forwarderOptions.anonaddy.baseUrl = o.forwardedAnonAddyBaseUrl; - } else if (o.forwardedService === "firefoxrelay") { - forwarder = new FirefoxRelayForwarder(); - forwarderOptions.apiKey = o.forwardedFirefoxApiToken; - } else if (o.forwardedService === "fastmail") { - forwarder = new FastmailForwarder(); - forwarderOptions.apiKey = o.forwardedFastmailApiToken; - } else if (o.forwardedService === "duckduckgo") { - forwarder = new DuckDuckGoForwarder(); - forwarderOptions.apiKey = o.forwardedDuckDuckGoToken; - } else if (o.forwardedService === "forwardemail") { - forwarder = new ForwardEmailForwarder(); - forwarderOptions.apiKey = o.forwardedForwardEmailApiToken; - forwarderOptions.forwardemail.domain = o.forwardedForwardEmailDomain; - } - - if (forwarder == null) { - return null; - } - - return forwarder.generate(this.apiService, forwarderOptions); - } - - getOptions$() { - return from(this.getOptions()); - } - - async getOptions(): Promise { - let options = await this.stateService.getUsernameGenerationOptions(); - if (options == null) { - options = Object.assign({}, DefaultOptions); - } else { - options = Object.assign({}, DefaultOptions, options); - } - await this.stateService.setUsernameGenerationOptions(options); - return options; - } - - async saveOptions(options: UsernameGeneratorOptions) { - await this.stateService.setUsernameGenerationOptions(options); - } - - private async randomString(length: number) { - let str = ""; - const charSet = "abcdefghijklmnopqrstuvwxyz1234567890"; - for (let i = 0; i < length; i++) { - const randomCharIndex = await this.cryptoService.randomNumber(0, charSet.length - 1); - str += charSet.charAt(randomCharIndex); - } - return str; - } - - // ref: https://stackoverflow.com/a/10073788 - private zeroPad(number: string, width: number) { - return number.length >= width - ? number - : new Array(width - number.length + 1).join("0") + number; - } -} diff --git a/libs/common/src/tools/generator/util.ts b/libs/common/src/tools/generator/util.ts new file mode 100644 index 00000000000..ee526fc6786 --- /dev/null +++ b/libs/common/src/tools/generator/util.ts @@ -0,0 +1,41 @@ +import { BehaviorSubject } from "rxjs"; + +import { SingleUserState, StateProvider, UserKeyDefinition } from "../../platform/state"; +import { UserId } from "../../types/guid"; + +/** construct a method that outputs a copy of `defaultValue` as an observable. */ +export function clone$PerUserId(defaultValue: Value) { + const _subjects = new Map>(); + + return (key: UserId) => { + let value = _subjects.get(key); + + if (value === undefined) { + value = new BehaviorSubject({ ...defaultValue }); + _subjects.set(key, value); + } + + return value.asObservable(); + }; +} + +/** construct a method that caches user-specific states by userid. */ +export function sharedByUserId(create: (userId: UserId) => SingleUserState) { + const _subjects = new Map>(); + + return (key: UserId) => { + let value = _subjects.get(key); + + if (value === undefined) { + value = create(key); + _subjects.set(key, value); + } + + return value; + }; +} + +/** construct a method that loads a user-specific state from the provider. */ +export function sharedStateByUserId(key: UserKeyDefinition, provider: StateProvider) { + return (id: UserId) => provider.getUser(id, key); +} diff --git a/libs/common/src/tools/generator/word-options.ts b/libs/common/src/tools/generator/word-options.ts new file mode 100644 index 00000000000..1c98d0bac8d --- /dev/null +++ b/libs/common/src/tools/generator/word-options.ts @@ -0,0 +1,6 @@ +export type WordOptions = { + /** set the first letter uppercase */ + titleCase?: boolean; + /** append a number */ + number?: boolean; +}; diff --git a/libs/common/src/tools/state/buffered-state.spec.ts b/libs/common/src/tools/state/buffered-state.spec.ts index 09419207caf..8fbb09cb38f 100644 --- a/libs/common/src/tools/state/buffered-state.spec.ts +++ b/libs/common/src/tools/state/buffered-state.spec.ts @@ -6,7 +6,7 @@ import { awaitAsync, trackEmissions, } from "../../../spec"; -import { GENERATOR_DISK, KeyDefinition } from "../../platform/state"; +import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; import { BufferedKeyDefinition } from "./buffered-key-definition"; @@ -16,8 +16,9 @@ const SomeUser = "SomeUser" as UserId; const accountService = mockAccountServiceWith(SomeUser); type SomeType = { foo: boolean; bar: boolean }; -const SOME_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { +const SOME_KEY = new UserKeyDefinition(GENERATOR_DISK, "fooBar", { deserializer: (jsonValue) => jsonValue as SomeType, + clearOn: [], }); const BUFFER_KEY = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { deserializer: (jsonValue) => jsonValue as SomeType, diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 0ea628cca11..34bc8193553 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; export abstract class CipherService { cipherViews$: Observable>; + ciphers$: Observable>; /** * An observable monitoring the add/edit cipher info saved to memory. */ diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 21e6324abbc..02db81b9290 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -142,6 +142,17 @@ export class IdentityView extends ItemView { return addressPart2; } + get fullAddressForCopy(): string { + let address = this.fullAddress; + if (this.city != null || this.state != null || this.postalCode != null) { + address += "\n" + this.fullAddressPart2; + } + if (this.country != null) { + address += "\n" + this.country; + } + return address; + } + static fromJSON(obj: Partial>): IdentityView { return Object.assign(new IdentityView(), obj); } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 9b9841169fa..ff99c8b1d84 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable, share, skipWhile, switchMap } from "rxjs"; +import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs"; import { SemVer } from "semver"; import { ApiService } from "../../abstractions/api.service"; @@ -125,13 +125,7 @@ export class CipherService implements CipherServiceAbstraction { switchMap(() => this.encryptedCiphersState.state$), map((ciphers) => ciphers ?? {}), ); - this.cipherViews$ = this.decryptedCiphersState.state$.pipe( - map((views) => views ?? {}), - - share({ - resetOnRefCountZero: true, - }), - ); + this.cipherViews$ = this.decryptedCiphersState.state$.pipe(map((views) => views ?? {})); this.addEditCipherInfo$ = this.addEditCipherInfoState.state$; } diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts index acce4a18aad..ce410727065 100644 --- a/libs/components/src/badge/badge.directive.ts +++ b/libs/components/src/badge/badge.directive.ts @@ -49,7 +49,7 @@ export class BadgeDirective implements FocusableElement { ] .concat(styles[this.variant]) .concat(this.hasHoverEffects ? hoverStyles[this.variant] : []) - .concat(this.truncate ? ["tw-truncate", "tw-max-w-40"] : []); + .concat(this.truncate ? ["tw-truncate", this.maxWidthClass] : []); } @HostBinding("attr.title") get title() { return this.truncate ? this.el.nativeElement.textContent.trim() : null; @@ -65,6 +65,8 @@ export class BadgeDirective implements FocusableElement { */ @Input() truncate = true; + @Input() maxWidthClass: `tw-max-w-${string}` = "tw-max-w-40"; + getFocusTarget() { return this.el.nativeElement; } diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts new file mode 100644 index 00000000000..2b79742c66d --- /dev/null +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -0,0 +1,78 @@ +import { Directive, HostBinding, HostListener, Input, OnChanges } from "@angular/core"; + +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; + +/** + * Directive to copy a specific field from a cipher on click. Uses the `CopyCipherFieldService` to + * handle the copying of the field and any necessary password re-prompting or totp generation. + * + * Automatically disables the host element if the field to copy is not available or null. + * + * @example + * ```html + * + * ``` + */ +@Directive({ + standalone: true, + selector: "[appCopyField]", +}) +export class CopyCipherFieldDirective implements OnChanges { + @Input({ + alias: "appCopyField", + required: true, + }) + action: Exclude; + + @Input({ required: true }) cipher: CipherView; + + constructor(private copyCipherFieldService: CopyCipherFieldService) {} + + @HostBinding("attr.disabled") + protected disabled: boolean | null = null; + + @HostListener("click") + async copy() { + const value = this.getValueToCopy(); + await this.copyCipherFieldService.copy(value, this.action, this.cipher); + } + + async ngOnChanges() { + await this.updateDisabledState(); + } + + private async updateDisabledState() { + this.disabled = + !this.cipher || + !this.getValueToCopy() || + (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) + ? true + : null; + } + + private getValueToCopy() { + switch (this.action) { + case "username": + return this.cipher.login?.username || this.cipher.identity?.username; + case "password": + return this.cipher.login?.password; + case "totp": + return this.cipher.login?.totp; + case "cardNumber": + return this.cipher.card?.number; + case "securityCode": + return this.cipher.card?.code; + case "email": + return this.cipher.identity?.email; + case "phone": + return this.cipher.identity?.phone; + case "address": + return this.cipher.identity?.fullAddressForCopy; + case "secureNote": + return this.cipher.notes; + default: + return null; + } + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index bf6b7f91c23..00fa0420802 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -1 +1,3 @@ export { PasswordRepromptService } from "./services/password-reprompt.service"; +export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; +export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts new file mode 100644 index 00000000000..57c77ebb0bd --- /dev/null +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -0,0 +1,170 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { ToastService } from "@bitwarden/components"; +import { CopyAction, CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; + +describe("CopyCipherFieldService", () => { + let service: CopyCipherFieldService; + let platformUtilsService: MockProxy; + let toastService: MockProxy; + let eventCollectionService: MockProxy; + let passwordRepromptService: MockProxy; + let totpService: MockProxy; + let i18nService: MockProxy; + let billingAccountProfileStateService: MockProxy; + + beforeEach(() => { + platformUtilsService = mock(); + toastService = mock(); + eventCollectionService = mock(); + passwordRepromptService = mock(); + totpService = mock(); + i18nService = mock(); + billingAccountProfileStateService = mock(); + + service = new CopyCipherFieldService( + platformUtilsService, + toastService, + eventCollectionService, + passwordRepromptService, + totpService, + i18nService, + billingAccountProfileStateService, + ); + }); + + describe("copy", () => { + let cipher: CipherView; + let valueToCopy: string; + let actionType: CopyAction; + let skipReprompt: boolean; + + beforeEach(() => { + cipher = mock(); + valueToCopy = "test"; + actionType = "username"; + skipReprompt = false; + }); + + it("should return early when valueToCopy is null", async () => { + valueToCopy = null; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should return early when cipher.viewPassword is false", async () => { + cipher.viewPassword = false; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should copy value to clipboard", async () => { + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith(valueToCopy); + }); + + it("should show a success toast on copy", async () => { + i18nService.t.mockReturnValueOnce("Username").mockReturnValueOnce("Username copied"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "Username copied", + title: null, + }); + expect(i18nService.t).toHaveBeenCalledWith("username"); + expect(i18nService.t).toHaveBeenCalledWith("valueCopied", "Username"); + }); + + describe("password reprompt", () => { + beforeEach(() => { + actionType = "password"; + cipher.reprompt = CipherRepromptType.Password; + }); + + it("should show password prompt when actionType requires it", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + }); + + it("should skip password prompt when cipher.reprompt is 'None'", async () => { + cipher.reprompt = CipherRepromptType.None; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalled(); + }); + + it("should skip password prompt when skipReprompt is true", async () => { + skipReprompt = true; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + }); + + it("should return early when password prompt is not confirmed", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(false); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + }); + + describe("totp", () => { + beforeEach(() => { + actionType = "totp"; + cipher.login = new LoginView(); + cipher.login.totp = "secret-totp"; + cipher.reprompt = CipherRepromptType.None; + cipher.organizationUseTotp = false; + }); + + it("should get TOTP code when allowed from premium", async () => { + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); + totpService.getCode.mockResolvedValue("123456"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); + }); + + it("should get TOTP code when allowed from organization", async () => { + cipher.organizationUseTotp = true; + totpService.getCode.mockResolvedValue("123456"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); + }); + + it("should return early when the user is not allowed to use TOTP", async () => { + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should return early when TOTP is not set", async () => { + cipher.login.totp = null; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + }); + + it("should collect an event when actionType has one", async () => { + actionType = "password"; + skipReprompt = true; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.Cipher_ClientCopiedPassword, + cipher.id, + ); + }); + }); +}); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts new file mode 100644 index 00000000000..9cd94f5ce63 --- /dev/null +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -0,0 +1,142 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +/** + * The types of fields that can be copied from a cipher. + */ +export type CopyAction = + | "username" + | "password" + | "totp" + | "cardNumber" + | "securityCode" + | "email" + | "phone" + | "address" + | "secureNote" + | "hiddenField"; + +type CopyActionInfo = { + /** + * The i18n key for the type of field being copied. Will be used to display a toast message. + */ + typeI18nKey: string; + + /** + * Whether the field is protected and requires password re-prompting before being copied. + */ + protected: boolean; + + /** + * Optional event to collect when the field is copied. + */ + event?: EventType; +}; + +const CopyActions: Record = { + username: { typeI18nKey: "username", protected: false }, + password: { + typeI18nKey: "password", + protected: true, + event: EventType.Cipher_ClientCopiedPassword, + }, + totp: { typeI18nKey: "verificationCodeTotp", protected: true }, + cardNumber: { typeI18nKey: "number", protected: true }, + securityCode: { + typeI18nKey: "securityCode", + protected: true, + event: EventType.Cipher_ClientCopiedCardCode, + }, + email: { typeI18nKey: "email", protected: false }, + phone: { typeI18nKey: "phone", protected: false }, + address: { typeI18nKey: "address", protected: false }, + secureNote: { typeI18nKey: "note", protected: false }, + hiddenField: { + typeI18nKey: "value", + protected: true, + event: EventType.Cipher_ClientCopiedHiddenField, + }, +}; + +@Injectable({ + providedIn: "root", +}) +export class CopyCipherFieldService { + constructor( + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + private eventCollectionService: EventCollectionService, + private passwordRepromptService: PasswordRepromptService, + private totpService: TotpService, + private i18nService: I18nService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) {} + + /** + * Copy a field value from a cipher to the clipboard. + * @param valueToCopy The value to copy. + * @param actionType The type of field being copied. + * @param cipher The cipher containing the field to copy. + * @param skipReprompt Whether to skip password re-prompting. + */ + async copy( + valueToCopy: string, + actionType: CopyAction, + cipher: CipherView, + skipReprompt: boolean = false, + ) { + const action = CopyActions[actionType]; + if ( + !skipReprompt && + cipher.reprompt !== CipherRepromptType.None && + action.protected && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return; + } + + if (valueToCopy == null || !cipher.viewPassword) { + return; + } + + if (actionType === "totp") { + if (!(await this.totpAllowed(cipher))) { + return; + } + valueToCopy = await this.totpService.getCode(valueToCopy); + } + + this.platformUtilsService.copyToClipboard(valueToCopy); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t(action.typeI18nKey)), + title: null, + }); + + if (action.event !== undefined) { + await this.eventCollectionService.collect(action.event, cipher.id); + } + } + + /** + * Determines if TOTP generation is allowed for a cipher and user. + */ + async totpAllowed(cipher: CipherView): Promise { + return ( + (cipher?.login?.hasTotp ?? false) && + (cipher.organizationUseTotp || + (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$))) + ); + } +}