Merge branch 'autofill/pm-8518-autofill-scripts-do-not-inject-into-sub-frames-on-install' into autofill/pm-5189-fix-issues-present-with-inline-menu-rendering-in-iframes

This commit is contained in:
Cesar Gonzalez 2024-06-04 17:48:22 -05:00 committed by GitHub
commit 02bbb41148
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 2009 additions and 1319 deletions

View File

@ -185,7 +185,7 @@
"message": "Continue to browser extension store?" "message": "Continue to browser extension store?"
}, },
"continueToBrowserExtensionStoreDesc": { "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": { "changeMasterPasswordOnWebConfirmation": {
"message": "You can change your master password on the Bitwarden web app." "message": "You can change your master password on the Bitwarden web app."
@ -389,6 +389,9 @@
"favorite": { "favorite": {
"message": "Favorite" "message": "Favorite"
}, },
"unfavorite": {
"message": "Unfavorite"
},
"notes": { "notes": {
"message": "Notes" "message": "Notes"
}, },
@ -410,6 +413,9 @@
"launch": { "launch": {
"message": "Launch" "message": "Launch"
}, },
"launchWebsite": {
"message": "Launch website"
},
"website": { "website": {
"message": "Website" "message": "Website"
}, },
@ -822,7 +828,7 @@
}, },
"exportPasswordDescription": { "exportPasswordDescription": {
"message": "This password will be used to export and import this file" "message": "This password will be used to export and import this file"
}, },
"accountRestrictedOptionDescription": { "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." "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": { "collections": {
"message": "Collections" "message": "Collections"
}, },
"nCollections": {
"message": "$COUNT$ collections",
"placeholders": {
"count": {
"content": "$1",
"example": "2"
}
}
},
"favorites": { "favorites": {
"message": "Favorites" "message": "Favorites"
}, },
@ -1660,6 +1675,9 @@
"autoFillAndSave": { "autoFillAndSave": {
"message": "Auto-fill and save" "message": "Auto-fill and save"
}, },
"fillAndSave": {
"message": "Fill and save"
},
"autoFillSuccessAndSavedUri": { "autoFillSuccessAndSavedUri": {
"message": "Item auto-filled and URI saved" "message": "Item auto-filled and URI saved"
}, },
@ -3281,7 +3299,7 @@
"clearFiltersOrTryAnother": { "clearFiltersOrTryAnother": {
"message": "Clear filters or try another search term" "message": "Clear filters or try another search term"
}, },
"copyInfo": { "copyInfoLabel": {
"message": "Copy info, $ITEMNAME$", "message": "Copy info, $ITEMNAME$",
"description": "Aria label for a button that opens a menu with options to copy information from an item.", "description": "Aria label for a button that opens a menu with options to copy information from an item.",
"placeholders": { "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$", "message": "More options, $ITEMNAME$",
"description": "Aria label for a button that opens a menu with more options for an item.", "description": "Aria label for a button that opens a menu with more options for an item.",
"placeholders": { "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": { "adminConsole": {
"message": "Admin Console" "message": "Admin Console"
}, },

View File

@ -4,7 +4,7 @@
[title]="'autofillSuggestions' | i18n" [title]="'autofillSuggestions' | i18n"
[showRefresh]="showRefresh" [showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()" (onRefresh)="refreshCurrentTab()"
showAutoFill showAutofillButton
></app-vault-list-items-container> ></app-vault-list-items-container>
<ng-container *ngIf="showEmptyAutofillTip$ | async"> <ng-container *ngIf="showEmptyAutofillTip$ | async">
<bit-section> <bit-section>

View File

@ -3,12 +3,12 @@ import { Component } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs"; import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; 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"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
@Component({ @Component({
@ -30,7 +30,7 @@ export class AutofillVaultListItemsComponent {
* The list of ciphers that can be used to autofill the current page. * The list of ciphers that can be used to autofill the current page.
* @protected * @protected
*/ */
protected autofillCiphers$: Observable<CipherView[]> = protected autofillCiphers$: Observable<PopupCipherView[]> =
this.vaultPopupItemsService.autoFillCiphers$; this.vaultPopupItemsService.autoFillCiphers$;
/** /**

View File

@ -0,0 +1,77 @@
<bit-item-action *ngIf="cipher.type === CipherType.Login">
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="loginOptions"
></button>
<bit-menu #loginOptions>
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="password" [cipher]="cipher">
{{ "copyPassword" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="totp" [cipher]="cipher">
{{ "copyVerificationCode" | i18n }}
</button>
</bit-menu>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.Card">
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="cardOptions"
></button>
<bit-menu #cardOptions>
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher">
{{ "copyNumber" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher">
{{ "copySecurityCode" | i18n }}
</button>
</bit-menu>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.Identity">
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="identityOptions"
></button>
<bit-menu #identityOptions>
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher">
{{ "copyEmail" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher">
{{ "copyPhone" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher">
{{ "copyAddress" | i18n }}
</button>
</bit-menu>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SecureNote">
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyNoteLabel' | i18n: cipher.name"
[title]="'copyNoteTitle' | i18n: cipher.name"
appCopyField="secureNote"
[cipher]="cipher"
></button>
</bit-item-action>

View File

@ -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() {}
}

View File

@ -0,0 +1,36 @@
<bit-item-action>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<ng-container *ngIf="isLogin && !hideLoginOptions">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem>
{{ "autofill" | i18n }}
</button>
<button type="button" bitMenuItem *ngIf="canEdit">
{{ "fillAndSave" | i18n }}
</button>
</ng-container>
<button type="button" bitMenuItem *ngIf="this.canLaunch" (click)="launchCipher()">
{{ "launchWebsite" | i18n }}
</button>
</ng-container>
<button type="button" bitMenuItem (click)="toggleFavorite()">
{{ favoriteText | i18n }}
</button>
<ng-container *ngIf="canEdit">
<a routerLink="" bitMenuItem (click)="clone()">
{{ "clone" | i18n }}
</a>
<button type="button" bitMenuItem>
{{ "assignCollections" | i18n }}
</button>
</ng-container>
</bit-menu>
</bit-item-action>

View File

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

View File

@ -13,31 +13,31 @@
</popup-section-header> </popup-section-header>
<bit-item-group> <bit-item-group>
<bit-item *ngFor="let cipher of ciphers"> <bit-item *ngFor="let cipher of ciphers">
<a bit-item-content [routerLink]="['/view-cipher']" [queryParams]="{ cipherId: cipher.id }"> <a
bit-item-content
[routerLink]="['/view-cipher']"
[queryParams]="{ cipherId: cipher.id }"
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon> <app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
{{ cipher.name }} {{ cipher.name }}
<i
class="bwi bwi-sm"
*ngIf="cipher.organizationId"
[ngClass]="cipher.orgIcon"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span> <span slot="secondary">{{ cipher.subTitle }}</span>
</a> </a>
<ng-container slot="end"> <ng-container slot="end">
<bit-item-action *ngIf="showAutoFill"> <bit-item-action *ngIf="showAutofillButton">
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button> <button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
</bit-item-action> </bit-item-action>
<bit-item-action> <app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<button <app-item-more-options
type="button" [cipher]="cipher"
bitIconButton="bwi-clone" [hideLoginOptions]="showAutofillButton"
size="small" ></app-item-more-options>
[attr.aria-label]="'copyInfo' | i18n: cipher.name"
></button>
</bit-item-action>
<bit-item-action>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
[attr.aria-label]="'moreOptions' | i18n: cipher.name"
></button>
</bit-item-action>
</ng-container> </ng-container>
</bit-item> </bit-item>
</bit-item-group> </bit-item-group>

View File

@ -3,7 +3,7 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul
import { RouterLink } from "@angular/router"; import { RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { import {
BadgeModule, BadgeModule,
ButtonModule, ButtonModule,
@ -14,6 +14,9 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; 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({ @Component({
imports: [ imports: [
@ -27,6 +30,8 @@ import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup
JslibModule, JslibModule,
PopupSectionHeaderComponent, PopupSectionHeaderComponent,
RouterLink, RouterLink,
ItemCopyActionsComponent,
ItemMoreOptionsComponent,
], ],
selector: "app-vault-list-items-container", selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html", templateUrl: "vault-list-items-container.component.html",
@ -37,7 +42,7 @@ export class VaultListItemsContainerComponent {
* The list of ciphers to display. * The list of ciphers to display.
*/ */
@Input() @Input()
ciphers: CipherView[]; ciphers: PopupCipherView[] = [];
/** /**
* Title for the vault list item section. * Title for the vault list item section.
@ -61,5 +66,19 @@ export class VaultListItemsContainerComponent {
* Option to show the autofill button for each item. * Option to show the autofill button for each item.
*/ */
@Input({ transform: booleanAttribute }) @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) {}
} }

View File

@ -22,10 +22,12 @@
</div> </div>
<ng-container *ngIf="!(showEmptyState$ | async)"> <ng-container *ngIf="!(showEmptyState$ | async)">
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)"> <div class="tw-fixed">
</app-vault-v2-search> <app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
</app-vault-v2-search>
<app-vault-list-filters></app-vault-list-filters> <app-vault-list-filters></app-vault-list-filters>
</div>
<div <div
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)" *ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"

View File

@ -1,13 +1,18 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { CipherId } from "@bitwarden/common/types/guid"; import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; 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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
@ -16,15 +21,20 @@ import { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupItemsService", () => { describe("VaultPopupItemsService", () => {
let testBed: TestBed;
let service: VaultPopupItemsService; let service: VaultPopupItemsService;
let allCiphers: Record<CipherId, CipherView>; let allCiphers: Record<CipherId, CipherView>;
let autoFillCiphers: CipherView[]; let autoFillCiphers: CipherView[];
let mockOrg: Organization;
let mockCollections: CollectionView[];
const cipherServiceMock = mock<CipherService>(); const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>(); const vaultSettingsServiceMock = mock<VaultSettingsService>();
const organizationServiceMock = mock<OrganizationService>(); const organizationServiceMock = mock<OrganizationService>();
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>(); const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>(); const searchService = mock<SearchService>();
const collectionService = mock<CollectionService>();
beforeEach(() => { beforeEach(() => {
allCiphers = cipherFactory(10); allCiphers = cipherFactory(10);
@ -39,11 +49,14 @@ describe("VaultPopupItemsService", () => {
cipherList[2].favorite = true; cipherList[2].favorite = true;
cipherList[3].favorite = true; cipherList[3].favorite = true;
cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
searchService.searchCiphers.mockImplementation(async () => cipherList); cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable();
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); ciphers.filter((c) => ["0", "1"].includes(c.id)),
);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false);
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
organization: null, organization: null,
@ -55,31 +68,61 @@ describe("VaultPopupItemsService", () => {
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers, (ciphers: CipherView[]) => ciphers,
); );
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest jest
.spyOn(BrowserApi, "getTabFromCurrentWindow") .spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
service = new VaultPopupItemsService(
cipherServiceMock, mockOrg = {
vaultSettingsServiceMock, id: "org1",
vaultPopupListFiltersServiceMock, name: "Organization 1",
organizationServiceMock, planProductType: ProductType.Enterprise,
searchService, } 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", () => { it("should be created", () => {
service = new VaultPopupItemsService( service = testBed.inject(VaultPopupItemsService);
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
expect(service).toBeTruthy(); 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$", () => { describe("autoFillCiphers$", () => {
it("should return empty array if there is no current tab", (done) => { it("should return empty array if there is no current tab", (done) => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
@ -100,18 +143,10 @@ describe("VaultPopupItemsService", () => {
it("should filter ciphers for the current tab and types", (done) => { it("should filter ciphers for the current tab and types", (done) => {
const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; const currentTab = { url: "https://example.com" } as chrome.tabs.Tab;
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); (vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject<boolean>).next(true);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); (vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject<boolean>).next(true);
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.autoFillCiphers$.subscribe((ciphers) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith(
@ -136,14 +171,6 @@ describe("VaultPopupItemsService", () => {
Object.values(allCiphers), Object.values(allCiphers),
); );
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.autoFillCiphers$.subscribe((ciphers) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers.length).toBe(10); expect(ciphers.length).toBe(10);
@ -159,19 +186,18 @@ describe("VaultPopupItemsService", () => {
}); });
it("should filter autoFillCiphers$ down to search term", (done) => { it("should filter autoFillCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login"; const searchText = "Login";
searchService.searchCiphers.mockImplementation(async () => { searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => {
return cipherList.filter((cipher) => { return ciphers.filter((cipher) => {
return cipher.name.includes(searchText); 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) => { service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers[0].name.includes(searchText)).toBe(true); expect(ciphers[0].name.includes(searchText)).toBe(true);
expect(ciphers.length).toBe(2); expect(ciphers.length).toBe(1);
done(); done();
}); });
}); });
@ -248,14 +274,7 @@ describe("VaultPopupItemsService", () => {
describe("emptyVault$", () => { describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => { it("should return true if there are no ciphers", (done) => {
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); cipherServiceMock.getAllDecrypted.mockResolvedValue([]);
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.emptyVault$.subscribe((empty) => { service.emptyVault$.subscribe((empty) => {
expect(empty).toBe(true); expect(empty).toBe(true);
done(); done();

View File

@ -1,4 +1,4 @@
import { Injectable } from "@angular/core"; import { inject, Injectable, NgZone } from "@angular/core";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@ -15,13 +15,18 @@ import {
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BrowserApi } from "../../../platform/browser/browser-api"; 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 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"; 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. * Observable that contains the list of all decrypted ciphers.
* @private * @private
*/ */
private _cipherList$: Observable<CipherView[]> = this.cipherService.cipherViews$.pipe( private _cipherList$: Observable<PopupCipherView[]> = 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)), 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<CipherView[]> = combineLatest([ private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._cipherList$, this._cipherList$,
this.searchText$, this.searchText$,
this.vaultPopupListFiltersService.filterFunction$, this.vaultPopupListFiltersService.filterFunction$,
@ -86,8 +112,9 @@ export class VaultPopupItemsService {
filterFunction(ciphers), filterFunction(ciphers),
searchText, searchText,
]), ]),
switchMap(([ciphers, searchText]) => switchMap(
this.searchService.searchCiphers(searchText, null, ciphers), ([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers) as Promise<PopupCipherView[]>,
), ),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
@ -98,7 +125,7 @@ export class VaultPopupItemsService {
* *
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
*/ */
autoFillCiphers$: Observable<CipherView[]> = combineLatest([ autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([
this._filteredCipherList$, this._filteredCipherList$,
this._otherAutoFillTypes$, this._otherAutoFillTypes$,
this._currentAutofillTab$, this._currentAutofillTab$,
@ -117,7 +144,7 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill. * List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by last used date, then by name. * Ciphers are sorted by last used date, then by name.
*/ */
favoriteCiphers$: Observable<CipherView[]> = combineLatest([ favoriteCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$, this.autoFillCiphers$,
this._filteredCipherList$, this._filteredCipherList$,
]).pipe( ]).pipe(
@ -134,7 +161,7 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name. * Ciphers are sorted by name.
*/ */
remainingCiphers$: Observable<CipherView[]> = combineLatest([ remainingCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$, this.autoFillCiphers$,
this.favoriteCiphers$, this.favoriteCiphers$,
this._filteredCipherList$, this._filteredCipherList$,
@ -204,6 +231,7 @@ export class VaultPopupItemsService {
private vaultPopupListFiltersService: VaultPopupListFiltersService, private vaultPopupListFiltersService: VaultPopupListFiltersService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private searchService: SearchService, private searchService: SearchService,
private collectionService: CollectionService,
) {} ) {}
/** /**

View File

@ -169,6 +169,13 @@ describe("VaultPopupListFiltersService", () => {
expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]); 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$", () => { 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) => { it("returns folders that have ciphers within the selected organization", (done) => {
service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => { service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1"]); expect(folders.map((f) => f.label)).toEqual(["Folder 1"]);

View File

@ -206,7 +206,7 @@ export class VaultPopupListFiltersService {
/** /**
* Folder array structured to be directly passed to `ChipSelectComponent` * Folder array structured to be directly passed to `ChipSelectComponent`
*/ */
folders$: Observable<ChipSelectOption<string>[]> = combineLatest([ folders$: Observable<ChipSelectOption<FolderView>[]> = combineLatest([
this.filters$.pipe( this.filters$.pipe(
distinctUntilChanged( distinctUntilChanged(
(previousFilter, currentFilter) => (previousFilter, currentFilter) =>
@ -258,13 +258,15 @@ export class VaultPopupListFiltersService {
nestedList: nestedFolders, 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` * Collection array structured to be directly passed to `ChipSelectComponent`
*/ */
collections$: Observable<ChipSelectOption<string>[]> = combineLatest([ collections$: Observable<ChipSelectOption<CollectionView>[]> = combineLatest([
this.filters$.pipe( this.filters$.pipe(
distinctUntilChanged( distinctUntilChanged(
(previousFilter, currentFilter) => (previousFilter, currentFilter) =>
@ -292,7 +294,9 @@ export class VaultPopupListFiltersService {
nestedList: nestedCollections, 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<T extends ITreeNodeObject>( private convertToChipSelectOption<T extends ITreeNodeObject>(
item: TreeNode<T>, item: TreeNode<T>,
icon: string,
): ChipSelectOption<T> { ): ChipSelectOption<T> {
return { return {
value: item.node, value: item.node,
label: item.node.name, label: item.node.name,
icon: "bwi-folder", // Organization & Folder icons are the same icon,
children: item.children children: item.children
? item.children.map(this.convertToChipSelectOption.bind(this)) ? item.children.map((i) => this.convertToChipSelectOption(i, icon))
: undefined, : undefined,
}; };
} }

View File

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

View File

@ -144,6 +144,7 @@
</bit-form-control> </bit-form-control>
<bit-form-control> <bit-form-control>
<input <input
id="manageUsers"
type="checkbox" type="checkbox"
bitCheckbox bitCheckbox
formControlName="manageUsers" formControlName="manageUsers"
@ -203,6 +204,7 @@
</bit-form-control> </bit-form-control>
<bit-form-control> <bit-form-control>
<input <input
id="manageUsers"
type="checkbox" type="checkbox"
bitCheckbox bitCheckbox
formControlName="manageUsers" formControlName="manageUsers"

View File

@ -146,19 +146,17 @@ export class UserSubscriptionComponent implements OnInit {
} }
}; };
adjustStorage = (add: boolean) => { adjustStorage = async (add: boolean) => {
return async () => { const dialogRef = openAdjustStorageDialog(this.dialogService, {
const dialogRef = openAdjustStorageDialog(this.dialogService, { data: {
data: { storageGbPrice: 4,
storageGbPrice: 4, add: add,
add: add, },
}, });
}); const result = await lastValueFrom(dialogRef.closed);
const result = await lastValueFrom(dialogRef.closed); if (result === AdjustStorageDialogResult.Adjusted) {
if (result === AdjustStorageDialogResult.Adjusted) { await this.load();
await this.load(); }
}
};
}; };
get subscriptionMarkedForCancel() { get subscriptionMarkedForCancel() {

View File

@ -12,6 +12,14 @@ import {
GlobalState, GlobalState,
} from "@bitwarden/common/platform/state"; } 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", { const DEEP_LINK_REDIRECT_URL = new KeyDefinition(ROUTER_DISK, "deepLinkRedirectUrl", {
deserializer: (value: string) => value, deserializer: (value: string) => value,
}); });

View File

@ -40,6 +40,7 @@ import { UpdatePasswordComponent } from "./auth/update-password.component";
import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component";
import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component";
import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component"; import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component";
import { DataProperties } from "./core";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component";
import { DomainRulesComponent } from "./settings/domain-rules.component"; import { DomainRulesComponent } from "./settings/domain-rules.component";
@ -54,7 +55,7 @@ const routes: Routes = [
{ {
path: "", path: "",
component: FrontendLayoutComponent, component: FrontendLayoutComponent,
data: { doNotSaveUrl: true }, data: { doNotSaveUrl: true } satisfies DataProperties,
children: [ children: [
{ {
path: "", path: "",
@ -66,17 +67,17 @@ const routes: Routes = [
{ {
path: "login-with-device", path: "login-with-device",
component: LoginViaAuthRequestComponent, component: LoginViaAuthRequestComponent,
data: { titleId: "loginWithDevice" }, data: { titleId: "loginWithDevice" } satisfies DataProperties,
}, },
{ {
path: "login-with-passkey", path: "login-with-passkey",
component: LoginViaWebAuthnComponent, component: LoginViaWebAuthnComponent,
data: { titleId: "loginWithPasskey" }, data: { titleId: "loginWithPasskey" } satisfies DataProperties,
}, },
{ {
path: "admin-approval-requested", path: "admin-approval-requested",
component: LoginViaAuthRequestComponent, component: LoginViaAuthRequestComponent,
data: { titleId: "adminApprovalRequested" }, data: { titleId: "adminApprovalRequested" } satisfies DataProperties,
}, },
{ path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] },
{ {
@ -88,7 +89,7 @@ const routes: Routes = [
path: "register", path: "register",
component: TrialInitiationComponent, component: TrialInitiationComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "createAccount" }, data: { titleId: "createAccount" } satisfies DataProperties,
}, },
{ {
path: "trial", path: "trial",
@ -99,18 +100,18 @@ const routes: Routes = [
path: "sso", path: "sso",
component: SsoComponent, component: SsoComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "enterpriseSingleSignOn" }, data: { titleId: "enterpriseSingleSignOn" } satisfies DataProperties,
}, },
{ {
path: "set-password", path: "set-password",
component: SetPasswordComponent, component: SetPasswordComponent,
data: { titleId: "setMasterPassword" }, data: { titleId: "setMasterPassword" } satisfies DataProperties,
}, },
{ {
path: "hint", path: "hint",
component: HintComponent, component: HintComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "passwordHint" }, data: { titleId: "passwordHint" } satisfies DataProperties,
}, },
{ {
path: "lock", path: "lock",
@ -122,12 +123,12 @@ const routes: Routes = [
path: "accept-organization", path: "accept-organization",
canActivate: [deepLinkGuard()], canActivate: [deepLinkGuard()],
component: AcceptOrganizationComponent, component: AcceptOrganizationComponent,
data: { titleId: "joinOrganization", doNotSaveUrl: false }, data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies DataProperties,
}, },
{ {
path: "accept-emergency", path: "accept-emergency",
canActivate: [deepLinkGuard()], canActivate: [deepLinkGuard()],
data: { titleId: "acceptEmergency", doNotSaveUrl: false }, data: { titleId: "acceptEmergency", doNotSaveUrl: false } satisfies DataProperties,
loadComponent: () => loadComponent: () =>
import("./auth/emergency-access/accept/accept-emergency.component").then( import("./auth/emergency-access/accept/accept-emergency.component").then(
(mod) => mod.AcceptEmergencyComponent, (mod) => mod.AcceptEmergencyComponent,
@ -137,26 +138,26 @@ const routes: Routes = [
path: "accept-families-for-enterprise", path: "accept-families-for-enterprise",
component: AcceptFamilySponsorshipComponent, component: AcceptFamilySponsorshipComponent,
canActivate: [deepLinkGuard()], canActivate: [deepLinkGuard()],
data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false }, data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false } satisfies DataProperties,
}, },
{ path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" },
{ {
path: "recover-2fa", path: "recover-2fa",
component: RecoverTwoFactorComponent, component: RecoverTwoFactorComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "recoverAccountTwoStep" }, data: { titleId: "recoverAccountTwoStep" } satisfies DataProperties,
}, },
{ {
path: "recover-delete", path: "recover-delete",
component: RecoverDeleteComponent, component: RecoverDeleteComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "deleteAccount" }, data: { titleId: "deleteAccount" } satisfies DataProperties,
}, },
{ {
path: "verify-recover-delete", path: "verify-recover-delete",
component: VerifyRecoverDeleteComponent, component: VerifyRecoverDeleteComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "deleteAccount" }, data: { titleId: "deleteAccount" } satisfies DataProperties,
}, },
{ {
path: "verify-recover-delete-org", path: "verify-recover-delete-org",
@ -168,30 +169,30 @@ const routes: Routes = [
path: "verify-recover-delete-provider", path: "verify-recover-delete-provider",
component: VerifyRecoverDeleteProviderComponent, component: VerifyRecoverDeleteProviderComponent,
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
data: { titleId: "deleteAccount" }, data: { titleId: "deleteAccount" } satisfies DataProperties,
}, },
{ {
path: "send/:sendId/:key", path: "send/:sendId/:key",
component: AccessComponent, component: AccessComponent,
data: { title: "Bitwarden Send" }, data: { titleId: "Bitwarden Send" } satisfies DataProperties,
}, },
{ {
path: "update-temp-password", path: "update-temp-password",
component: UpdateTempPasswordComponent, component: UpdateTempPasswordComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { titleId: "updateTempPassword" }, data: { titleId: "updateTempPassword" } satisfies DataProperties,
}, },
{ {
path: "update-password", path: "update-password",
component: UpdatePasswordComponent, component: UpdatePasswordComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { titleId: "updatePassword" }, data: { titleId: "updatePassword" } satisfies DataProperties,
}, },
{ {
path: "remove-password", path: "remove-password",
component: RemovePasswordComponent, component: RemovePasswordComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { titleId: "removeMasterPassword" }, data: { titleId: "removeMasterPassword" } satisfies DataProperties,
}, },
{ {
path: "migrate-legacy-encryption", path: "migrate-legacy-encryption",
@ -211,21 +212,29 @@ const routes: Routes = [
path: "vault", path: "vault",
loadChildren: () => VaultModule, loadChildren: () => VaultModule,
}, },
{ path: "sends", component: SendComponent, data: { titleId: "send" } }, {
path: "sends",
component: SendComponent,
data: { titleId: "send" } satisfies DataProperties,
},
{ {
path: "create-organization", path: "create-organization",
component: CreateOrganizationComponent, component: CreateOrganizationComponent,
data: { titleId: "newOrganization" }, data: { titleId: "newOrganization" } satisfies DataProperties,
}, },
{ {
path: "settings", path: "settings",
children: [ children: [
{ path: "", pathMatch: "full", redirectTo: "account" }, { path: "", pathMatch: "full", redirectTo: "account" },
{ path: "account", component: AccountComponent, data: { titleId: "myAccount" } }, {
path: "account",
component: AccountComponent,
data: { titleId: "myAccount" } satisfies DataProperties,
},
{ {
path: "preferences", path: "preferences",
component: PreferencesComponent, component: PreferencesComponent,
data: { titleId: "preferences" }, data: { titleId: "preferences" } satisfies DataProperties,
}, },
{ {
path: "security", path: "security",
@ -234,7 +243,7 @@ const routes: Routes = [
{ {
path: "domain-rules", path: "domain-rules",
component: DomainRulesComponent, component: DomainRulesComponent,
data: { titleId: "domainRules" }, data: { titleId: "domainRules" } satisfies DataProperties,
}, },
{ {
path: "subscription", path: "subscription",
@ -249,19 +258,19 @@ const routes: Routes = [
{ {
path: "", path: "",
component: EmergencyAccessComponent, component: EmergencyAccessComponent,
data: { titleId: "emergencyAccess" }, data: { titleId: "emergencyAccess" } satisfies DataProperties,
}, },
{ {
path: ":id", path: ":id",
component: EmergencyAccessViewComponent, component: EmergencyAccessViewComponent,
data: { titleId: "emergencyAccess" }, data: { titleId: "emergencyAccess" } satisfies DataProperties,
}, },
], ],
}, },
{ {
path: "sponsored-families", path: "sponsored-families",
component: SponsoredFamiliesComponent, 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), import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent),
data: { data: {
titleId: "importData", titleId: "importData",
}, } satisfies DataProperties,
}, },
{ {
path: "export", path: "export",
@ -286,7 +295,7 @@ const routes: Routes = [
{ {
path: "generator", path: "generator",
component: GeneratorComponent, component: GeneratorComponent,
data: { titleId: "generator" }, data: { titleId: "generator" } satisfies DataProperties,
}, },
], ],
}, },

View File

@ -17,6 +17,7 @@ import {
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -94,6 +95,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private smOnboardingTasksService: SMOnboardingTasksService, private smOnboardingTasksService: SMOnboardingTasksService,
private logService: LogService,
) {} ) {}
ngOnInit() { ngOnInit() {
@ -297,12 +299,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
} }
copySecretValue(id: string) { async copySecretValue(id: string) {
SecretsListComponent.copySecretValue( await SecretsListComponent.copySecretValue(
id, id,
this.platformUtilsService, this.platformUtilsService,
this.i18nService, this.i18nService,
this.secretService, this.secretService,
this.logService,
); );
} }
@ -310,11 +313,9 @@ export class OverviewComponent implements OnInit, OnDestroy {
SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService);
} }
protected hideOnboarding() { protected async hideOnboarding() {
this.showOnboarding = false; 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. await this.saveCompletedTasks(this.organizationId, {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.saveCompletedTasks(this.organizationId, {
importSecrets: true, importSecrets: true,
createSecret: true, createSecret: true,
createProject: true, createProject: true,

View File

@ -82,9 +82,7 @@ export class ProjectDialogComponent implements OnInit {
const projectView = this.getProjectView(); const projectView = this.getProjectView();
if (this.data.operation === OperationType.Add) { if (this.data.operation === OperationType.Add) {
const newProject = await this.createProject(projectView); 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. await this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]);
} else { } else {
projectView.id = this.data.projectId; projectView.id = this.data.projectId;
await this.updateProject(projectView); await this.updateProject(projectView);

View File

@ -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<OrganizationService>;
let routerService: MockProxy<RouterService>;
let projectServiceMock: MockProxy<ProjectService>;
let i18nServiceMock: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
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<OrganizationService>();
routerService = mock<RouterService>();
projectServiceMock = mock<ProjectService>();
i18nServiceMock = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>();
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");
});
});

View File

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

View File

@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs"; import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -38,8 +39,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
}), }),
), ),
catchError(async () => { catchError(async () => {
this.logService.info("Error fetching project people access policies.");
await this.router.navigate(["/sm", this.organizationId, "projects"]); 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 platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private accessPolicySelectorService: AccessPolicySelectorService, private accessPolicySelectorService: AccessPolicySelectorService,
private logService: LogService,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {

View File

@ -4,6 +4,7 @@ import { combineLatest, combineLatestWith, filter, Observable, startWith, switch
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -42,6 +43,7 @@ export class ProjectSecretsComponent {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private logService: LogService,
) {} ) {}
ngOnInit() { ngOnInit() {
@ -109,12 +111,13 @@ export class ProjectSecretsComponent {
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
} }
copySecretValue(id: string) { async copySecretValue(id: string) {
SecretsListComponent.copySecretValue( await SecretsListComponent.copySecretValue(
id, id,
this.platformUtilsService, this.platformUtilsService,
this.i18nService, this.i18nService,
this.secretService, this.secretService,
this.logService,
); );
} }

View File

@ -1,9 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { import {
catchError,
combineLatest, combineLatest,
EMPTY,
filter, filter,
Observable, Observable,
startWith, startWith,
@ -58,18 +56,6 @@ export class ProjectComponent implements OnInit, OnDestroy {
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe( this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)), 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)); const projectId$ = this.route.params.pipe(map((p) => p.projectId));

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; import { RouterModule, Routes } from "@angular/router";
import { projectAccessGuard } from "./guards/project-access.guard";
import { ProjectPeopleComponent } from "./project/project-people.component"; import { ProjectPeopleComponent } from "./project/project-people.component";
import { ProjectSecretsComponent } from "./project/project-secrets.component"; import { ProjectSecretsComponent } from "./project/project-secrets.component";
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component"; import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
@ -15,6 +16,7 @@ const routes: Routes = [
{ {
path: ":projectId", path: ":projectId",
component: ProjectComponent, component: ProjectComponent,
canActivate: [projectAccessGuard],
children: [ children: [
{ {
path: "", path: "",

View File

@ -199,7 +199,7 @@ export class SecretDialogComponent implements OnInit {
return await this.projectService.create(this.data.organizationId, projectView); return await this.projectService.create(this.data.organizationId, projectView);
} }
protected openDeleteSecretDialog() { protected async openDeleteSecretDialog() {
const secretListView: SecretListView[] = this.getSecretListView(); const secretListView: SecretListView[] = this.getSecretListView();
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>( const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
@ -212,9 +212,7 @@ export class SecretDialogComponent implements OnInit {
); );
// If the secret is deleted, chain close this dialog after the delete dialog // 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. await lastValueFrom(dialogRef.closed).then(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
lastValueFrom(dialogRef.closed).then(
(closeData) => closeData !== undefined && this.dialogRef.close(), (closeData) => closeData !== undefined && this.dialogRef.close(),
); );
} }

View File

@ -4,6 +4,7 @@ import { combineLatestWith, Observable, startWith, switchMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -39,6 +40,7 @@ export class SecretsComponent implements OnInit {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private logService: LogService,
) {} ) {}
ngOnInit() { ngOnInit() {
@ -97,12 +99,13 @@ export class SecretsComponent implements OnInit {
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
} }
copySecretValue(id: string) { async copySecretValue(id: string) {
SecretsListComponent.copySecretValue( await SecretsListComponent.copySecretValue(
id, id,
this.platformUtilsService, this.platformUtilsService,
this.i18nService, this.i18nService,
this.secretService, this.secretService,
this.logService,
); );
} }

View File

@ -47,9 +47,7 @@ export class ServiceAccountDialogComponent {
async ngOnInit() { async ngOnInit() {
if (this.data.operation == OperationType.Edit) { 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. await this.loadData();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loadData();
} }
} }

View File

@ -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<OrganizationService>;
let routerService: MockProxy<RouterService>;
let serviceAccountServiceMock: MockProxy<ServiceAccountService>;
let i18nServiceMock: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
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<OrganizationService>();
routerService = mock<RouterService>();
serviceAccountServiceMock = mock<ServiceAccountService>();
i18nServiceMock = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>();
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");
});
});

View File

@ -1,6 +1,9 @@
import { inject } from "@angular/core"; import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; 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"; import { ServiceAccountService } from "../service-account.service";
/** /**
@ -8,6 +11,8 @@ import { ServiceAccountService } from "../service-account.service";
*/ */
export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const serviceAccountService = inject(ServiceAccountService); const serviceAccountService = inject(ServiceAccountService);
const platformUtilsService = inject(PlatformUtilsService);
const i18nService = inject(I18nService);
try { try {
const serviceAccount = await serviceAccountService.getByServiceAccountId( const serviceAccount = await serviceAccountService.getByServiceAccountId(
@ -18,6 +23,12 @@ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedR
return true; return true;
} }
} catch { } catch {
platformUtilsService.showToast(
"error",
null,
i18nService.t("notFound", i18nService.t("machineAccount")),
);
return createUrlTreeFromSnapshot(route, [ return createUrlTreeFromSnapshot(route, [
"/sm", "/sm",
route.params.organizationId, route.params.organizationId,

View File

@ -1,15 +1,6 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { import { Subject, combineLatest, filter, startWith, switchMap, takeUntil } from "rxjs";
EMPTY,
Subject,
catchError,
combineLatest,
filter,
startWith,
switchMap,
takeUntil,
} from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -42,18 +33,6 @@ export class ServiceAccountComponent implements OnInit, OnDestroy {
params.organizationId, 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( constructor(

View File

@ -93,8 +93,9 @@
variant="secondary" variant="secondary"
class="tw-ml-1" class="tw-ml-1"
[title]="project.name" [title]="project.name"
maxWidthClass="tw-max-w-60"
> >
{{ project.name | ellipsis: 32 }} {{ project.name }}
</span> </span>
<span *ngIf="secret.projects.length === 0" bitBadge variant="warning" class="tw-ml-1" <span *ngIf="secret.projects.length === 0" bitBadge variant="warning" class="tw-ml-1"
><i class="bwi bwi-fw bwi-exclamation-triangle tw-mr-1" aria-hidden="true"></i ><i class="bwi bwi-fw bwi-exclamation-triangle tw-mr-1" aria-hidden="true"></i

View File

@ -3,6 +3,7 @@ import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core
import { Subject, takeUntil } from "rxjs"; import { Subject, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TableDataSource } from "@bitwarden/components"; import { TableDataSource } from "@bitwarden/components";
@ -134,22 +135,24 @@ export class SecretsListComponent implements OnDestroy {
/** /**
* TODO: Refactor to smart component and remove * TODO: Refactor to smart component and remove
*/ */
static copySecretValue( static async copySecretValue(
id: string, id: string,
platformUtilsService: PlatformUtilsService, platformUtilsService: PlatformUtilsService,
i18nService: I18nService, i18nService: I18nService,
secretService: SecretService, secretService: SecretService,
logService: LogService,
) { ) {
const value = secretService.getBySecretId(id).then((secret) => secret.value); try {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. const value = await secretService.getBySecretId(id).then((secret) => secret.value);
// eslint-disable-next-line @typescript-eslint/no-floating-promises platformUtilsService.copyToClipboard(value);
SecretsListComponent.copyToClipboardAsync(value, platformUtilsService).then(() => {
platformUtilsService.showToast( platformUtilsService.showToast(
"success", "success",
null, null,
i18nService.t("valueCopied", i18nService.t("value")), i18nService.t("valueCopied", i18nService.t("value")),
); );
}); } catch {
logService.info("Error fetching secret value.");
}
} }
static copySecretUuid( static copySecretUuid(

View File

@ -13,9 +13,11 @@
</h1> </h1>
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p> <p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
</div> </div>
<div class="tw-mb-auto tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center"> <div
class="tw-mb-auto tw-min-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
>
<div <div
class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-min-w-64 sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8" class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-min-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
> >
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

@ -119,6 +119,24 @@ export const WithLongContent: Story = {
}), }),
}; };
export const WithThinPrimaryContent: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
<div class="text-center">Lorem ipsum</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithIcon: Story = { export const WithIcon: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,

View File

@ -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 */ /** Test class to enable async awaiting of observable emissions */
export class ObservableTracker<T> { export class ObservableTracker<T> {
private subscription: Subscription; private subscription: Subscription;
private emissionReceived = new Subject<T>();
emissions: T[] = []; emissions: T[] = [];
constructor(private observable: Observable<T>) { constructor(observable: Observable<T>) {
this.emissions = this.trackEmissions(observable); this.emissions = this.trackEmissions(observable);
} }
@ -21,7 +22,7 @@ export class ObservableTracker<T> {
*/ */
async expectEmission(msTimeout = 50): Promise<T> { async expectEmission(msTimeout = 50): Promise<T> {
return await firstValueFrom( return await firstValueFrom(
this.observable.pipe( this.emissionReceived.pipe(
timeout({ timeout({
first: msTimeout, first: msTimeout,
with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")), with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")),
@ -34,40 +35,38 @@ export class ObservableTracker<T> {
* @param count The number of emissions to wait for * @param count The number of emissions to wait for
*/ */
async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> { async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> {
for (let i = 0; i < count - this.emissions.length; i++) { while (this.emissions.length < count) {
await this.expectEmission(msTimeout); await this.expectEmission(msTimeout);
} }
return this.emissions; return this.emissions;
} }
private trackEmissions<T>(observable: Observable<T>): T[] { private trackEmissions(observable: Observable<T>): T[] {
const emissions: T[] = []; const emissions: T[] = [];
this.subscription = observable.subscribe((value) => { this.subscription = observable.subscribe((value) => {
switch (value) { if (value == null) {
case undefined: this.emissionReceived.next(null);
case null: return;
emissions.push(value);
return;
default:
// process by type
break;
} }
switch (typeof value) { switch (typeof value) {
case "string": case "string":
case "number": case "number":
case "boolean": case "boolean":
emissions.push(value); this.emissionReceived.next(value);
break; break;
case "symbol": case "symbol":
// Cheating types to make symbols work at all // Cheating types to make symbols work at all
emissions.push(value.toString() as T); this.emissionReceived.next(value as T);
break; break;
default: { default: {
emissions.push(clone(value)); this.emissionReceived.next(clone(value));
} }
} }
}); });
this.emissionReceived.subscribe((value) => {
emissions.push(value);
});
return emissions; return emissions;
} }
} }

View File

@ -1,20 +1,23 @@
import { FakeGlobalStateProvider } from "../../../spec"; import { FakeGlobalState, FakeGlobalStateProvider, ObservableTracker } from "../../../spec";
import { Utils } from "../misc/utils"; import { Utils } from "../misc/utils";
import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service"; import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service";
describe("AppIdService", () => { describe("AppIdService", () => {
const globalStateProvider = new FakeGlobalStateProvider(); let globalStateProvider: FakeGlobalStateProvider;
const appIdState = globalStateProvider.getFake(APP_ID_KEY); let appIdState: FakeGlobalState<string>;
const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); let anonymousAppIdState: FakeGlobalState<string>;
let sut: AppIdService; let sut: AppIdService;
beforeEach(() => { beforeEach(() => {
globalStateProvider = new FakeGlobalStateProvider();
appIdState = globalStateProvider.getFake(APP_ID_KEY);
anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY);
sut = new AppIdService(globalStateProvider); sut = new AppIdService(globalStateProvider);
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.resetAllMocks();
}); });
describe("getAppId", () => { describe("getAppId", () => {
@ -26,19 +29,18 @@ describe("AppIdService", () => {
expect(appId).toBe("existingAppId"); expect(appId).toBe("existingAppId");
}); });
it.each([null, undefined])( it("creates a new appId only once", async () => {
"uses the util function to create a new id when it AppId does not exist", appIdState.stateSubject.next(null);
async (value) => {
appIdState.stateSubject.next(value);
const spy = jest.spyOn(Utils, "newGuid");
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); appIdState.stateSubject.next(value);
const appId = await sut.getAppId(); const appId = await sut.getAppId();
@ -46,16 +48,23 @@ describe("AppIdService", () => {
expect(appId).toMatch(Utils.guidRegex); expect(appId).toMatch(Utils.guidRegex);
}); });
it.each([null, undefined])( it.each([null, undefined])("stores the new guid when %s", async (value) => {
"stores the new guid when it an existing one is not found", appIdState.stateSubject.next(value);
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", () => { describe("getAnonymousAppId", () => {
@ -67,17 +76,16 @@ describe("AppIdService", () => {
expect(appId).toBe("existingAppId"); expect(appId).toBe("existingAppId");
}); });
it.each([null, undefined])( it("creates a new anonymousAppId only once", async () => {
"uses the util function to create a new id when it AppId does not exist", anonymousAppIdState.stateSubject.next(null);
async (value) => {
anonymousAppIdState.stateSubject.next(value);
const spy = jest.spyOn(Utils, "newGuid");
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) => { it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
anonymousAppIdState.stateSubject.next(value); anonymousAppIdState.stateSubject.next(value);
@ -97,5 +105,15 @@ describe("AppIdService", () => {
expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId); 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");
});
}); });
}); });

View File

@ -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 { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service";
import { Utils } from "../misc/utils"; import { Utils } from "../misc/utils";
@ -19,20 +19,28 @@ export class AppIdService implements AppIdServiceAbstraction {
const appIdState = globalStateProvider.get(APP_ID_KEY); const appIdState = globalStateProvider.get(APP_ID_KEY);
const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY); const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY);
this.appId$ = appIdState.state$.pipe( this.appId$ = appIdState.state$.pipe(
tap(async (appId) => { concatMap(async (appId) => {
if (!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( this.anonymousAppId$ = anonymousAppIdState.state$.pipe(
tap(async (appId) => { concatMap(async (appId) => {
if (!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(),
); );
} }

View File

@ -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<Entry>(list: Array<Entry>): Promise<Entry>;
/** 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<string>, options?: WordOptions): Promise<string>;
/** 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<Entry>(items: Array<Entry>): Promise<Array<Entry>>;
/** 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<string>;
/** 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<number>;
}

View File

@ -40,11 +40,11 @@ import {
import { import {
GeneratedPasswordHistory, GeneratedPasswordHistory,
PasswordGenerationOptions, PasswordGenerationOptions,
PasswordGenerationService,
PasswordGeneratorOptions, PasswordGeneratorOptions,
PasswordGeneratorPolicy, PasswordGeneratorPolicy,
PasswordGeneratorStrategy, PasswordGeneratorStrategy,
} from "./password"; } from "./password";
import { CryptoServiceRandomizer } from "./random";
type MappedOptions = { type MappedOptions = {
generator: GeneratorNavigation; generator: GeneratorNavigation;
@ -60,17 +60,15 @@ export function legacyPasswordGenerationServiceFactory(
accountService: AccountService, accountService: AccountService,
stateProvider: StateProvider, stateProvider: StateProvider,
): PasswordGenerationServiceAbstraction { ): PasswordGenerationServiceAbstraction {
// FIXME: Once the password generation service is replaced with this service const randomizer = new CryptoServiceRandomizer(cryptoService);
// in the clients, factor out the deprecated service in its entirety.
const deprecatedService = new PasswordGenerationService(cryptoService, null, null);
const passwords = new DefaultGeneratorService( const passwords = new DefaultGeneratorService(
new PasswordGeneratorStrategy(deprecatedService, stateProvider), new PasswordGeneratorStrategy(randomizer, stateProvider),
policyService, policyService,
); );
const passphrases = new DefaultGeneratorService( const passphrases = new DefaultGeneratorService(
new PassphraseGeneratorStrategy(deprecatedService, stateProvider), new PassphraseGeneratorStrategy(randomizer, stateProvider),
policyService, policyService,
); );

View File

@ -14,6 +14,7 @@ import { DefaultGeneratorService } from "./default-generator.service";
import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service";
import { GeneratorNavigation } from "./navigation/generator-navigation"; import { GeneratorNavigation } from "./navigation/generator-navigation";
import { NoPolicy } from "./no-policy"; import { NoPolicy } from "./no-policy";
import { CryptoServiceRandomizer } from "./random";
import { import {
CatchallGeneratorStrategy, CatchallGeneratorStrategy,
SubaddressGeneratorStrategy, SubaddressGeneratorStrategy,
@ -37,7 +38,6 @@ import {
} from "./username/options/forwarder-options"; } from "./username/options/forwarder-options";
import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; import { SubaddressGenerationOptions } from "./username/subaddress-generator-options";
import { UsernameGeneratorOptions } from "./username/username-generation-options"; import { UsernameGeneratorOptions } from "./username/username-generation-options";
import { UsernameGenerationService } from "./username/username-generation.service";
type MappedOptions = { type MappedOptions = {
generator: GeneratorNavigation; generator: GeneratorNavigation;
@ -65,22 +65,20 @@ export function legacyUsernameGenerationServiceFactory(
accountService: AccountService, accountService: AccountService,
stateProvider: StateProvider, stateProvider: StateProvider,
): UsernameGenerationServiceAbstraction { ): UsernameGenerationServiceAbstraction {
// FIXME: Once the username generation service is replaced with this service const randomizer = new CryptoServiceRandomizer(cryptoService);
// in the clients, factor out the deprecated service in its entirety.
const deprecatedService = new UsernameGenerationService(cryptoService, null, null);
const effUsername = new DefaultGeneratorService( const effUsername = new DefaultGeneratorService(
new EffUsernameGeneratorStrategy(deprecatedService, stateProvider), new EffUsernameGeneratorStrategy(randomizer, stateProvider),
policyService, policyService,
); );
const subaddress = new DefaultGeneratorService( const subaddress = new DefaultGeneratorService(
new SubaddressGeneratorStrategy(deprecatedService, stateProvider), new SubaddressGeneratorStrategy(randomizer, stateProvider),
policyService, policyService,
); );
const catchall = new DefaultGeneratorService( const catchall = new DefaultGeneratorService(
new CatchallGeneratorStrategy(deprecatedService, stateProvider), new CatchallGeneratorStrategy(randomizer, stateProvider),
policyService, policyService,
); );

View File

@ -11,7 +11,7 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; 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 { PASSPHRASE_SETTINGS } from "../key-definitions";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
@ -65,8 +65,8 @@ describe("Password generation strategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("should use password settings key", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>(); const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new PassphraseGeneratorStrategy(legacy, provider); const strategy = new PassphraseGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser); strategy.durableState(SomeUser);
@ -86,36 +86,14 @@ describe("Password generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new PassphraseGeneratorStrategy(legacy, null); const strategy = new PassphraseGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
}); });
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it.todo("should generate a password using the given options");
const legacy = mock<PasswordGenerationServiceAbstraction>();
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<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);
expect(legacy.generatePassphrase).toHaveBeenCalledWith({ type: "passphrase" });
});
}); });
}); });

View File

@ -1,25 +1,20 @@
import { BehaviorSubject, map, pipe } from "rxjs";
import { GeneratorStrategy } from ".."; import { GeneratorStrategy } from "..";
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { EFFLongWordList } from "../../../platform/misc/wordlist";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { Randomizer } from "../abstractions/randomizer";
import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
import { PASSPHRASE_SETTINGS } from "../key-definitions"; 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 { import {
PassphraseGenerationOptions, PassphraseGenerationOptions,
DefaultPassphraseGenerationOptions, DefaultPassphraseGenerationOptions,
} from "./passphrase-generation-options"; } from "./passphrase-generation-options";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import {
DisabledPassphraseGeneratorPolicy,
PassphraseGeneratorPolicy,
leastPrivilege,
} from "./passphrase-generator-policy";
/** {@link GeneratorStrategy} */ /** Generates passphrases composed of random words */
export class PassphraseGeneratorStrategy export class PassphraseGeneratorStrategy
implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy> implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy>
{ {
@ -28,36 +23,48 @@ export class PassphraseGeneratorStrategy
* @param stateProvider provides durable state * @param stateProvider provides durable state
*/ */
constructor( constructor(
private legacy: PasswordGenerationServiceAbstraction, private randomizer: Randomizer,
private stateProvider: StateProvider, private stateProvider: StateProvider,
) {} ) {}
/** {@link GeneratorStrategy.durableState} */ // configuration
durableState(id: UserId) { durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS); defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions);
} readonly policy = PolicyType.PasswordGenerator;
/** Gets the default options. */
defaults$(_: UserId) {
return new BehaviorSubject({ ...DefaultPassphraseGenerationOptions }).asObservable();
}
/** {@link GeneratorStrategy.policy} */
get policy() {
return PolicyType.PasswordGenerator;
}
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() { toEvaluator() {
return pipe( return mapPolicyToEvaluator(Policies.Passphrase);
reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy),
distinctIfShallowMatch(),
map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)),
);
} }
/** {@link GeneratorStrategy.generate} */ // algorithm
generate(options: PassphraseGenerationOptions): Promise<string> { async generate(options: PassphraseGenerationOptions): Promise<string> {
return this.legacy.generatePassphrase({ ...options, type: "passphrase" }); 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);
} }
} }

View File

@ -7,5 +7,4 @@ export { PasswordGeneratorStrategy } from "./password-generator-strategy";
// legacy interfaces // legacy interfaces
export { PasswordGeneratorOptions } from "./password-generator-options"; export { PasswordGeneratorOptions } from "./password-generator-options";
export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
export { PasswordGenerationService } from "./password-generation.service";
export { GeneratedPasswordHistory } from "./generated-password-history"; export { GeneratedPasswordHistory } from "./generated-password-history";

View File

@ -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<string> {
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<string> {
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<PasswordGeneratorPolicyOptions> {
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<GeneratedPasswordHistory[]> {
const hasKey = await this.cryptoService.hasUserKey();
if (!hasKey) {
return new Array<GeneratedPasswordHistory>();
}
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<GeneratedPasswordHistory>();
}
async addHistory(password: string): Promise<void> {
// 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<GeneratedPasswordHistory[]> {
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<GeneratedPasswordHistory[]> {
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<GeneratedPasswordHistory[]> {
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]];
}
}
}

View File

@ -12,13 +12,13 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { PASSWORD_SETTINGS } from "../key-definitions"; import { PASSWORD_SETTINGS } from "../key-definitions";
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import { import {
DefaultPasswordGenerationOptions, DefaultPasswordGenerationOptions,
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptionsEvaluator, PasswordGeneratorOptionsEvaluator,
PasswordGeneratorStrategy, PasswordGeneratorStrategy,
} from "."; } from ".";
@ -74,8 +74,8 @@ describe("Password generation strategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("should use password settings key", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>(); const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new PasswordGeneratorStrategy(legacy, provider); const strategy = new PasswordGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser); strategy.durableState(SomeUser);
@ -95,40 +95,14 @@ describe("Password generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new PasswordGeneratorStrategy(legacy, null); const strategy = new PasswordGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
}); });
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it.todo("should generate a password using the given options");
const legacy = mock<PasswordGenerationServiceAbstraction>();
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<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);
expect(legacy.generatePassword).toHaveBeenCalledWith({ type: "password" });
});
}); });
}); });

View File

@ -1,25 +1,19 @@
import { BehaviorSubject, map, pipe } from "rxjs";
import { GeneratorStrategy } from ".."; import { GeneratorStrategy } from "..";
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { Randomizer } from "../abstractions/randomizer";
import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
import { PASSWORD_SETTINGS } from "../key-definitions"; 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 { import {
DefaultPasswordGenerationOptions, DefaultPasswordGenerationOptions,
PasswordGenerationOptions, PasswordGenerationOptions,
} from "./password-generation-options"; } from "./password-generation-options";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; import { PasswordGeneratorPolicy } from "./password-generator-policy";
import {
DisabledPasswordGeneratorPolicy,
PasswordGeneratorPolicy,
leastPrivilege,
} from "./password-generator-policy";
/** {@link GeneratorStrategy} */ /** Generates passwords composed of random characters */
export class PasswordGeneratorStrategy export class PasswordGeneratorStrategy
implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy> implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy>
{ {
@ -27,36 +21,108 @@ export class PasswordGeneratorStrategy
* @param legacy generates the password * @param legacy generates the password
*/ */
constructor( constructor(
private legacy: PasswordGenerationServiceAbstraction, private randomizer: Randomizer,
private stateProvider: StateProvider, private stateProvider: StateProvider,
) {} ) {}
/** {@link GeneratorStrategy.durableState} */ // configuration
durableState(id: UserId) { durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
return this.stateProvider.getUser(id, PASSWORD_SETTINGS); defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions);
} readonly policy = PolicyType.PasswordGenerator;
/** Gets the default options. */
defaults$(_: UserId) {
return new BehaviorSubject({ ...DefaultPasswordGenerationOptions }).asObservable();
}
/** {@link GeneratorStrategy.policy} */
get policy() {
return PolicyType.PasswordGenerator;
}
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() { toEvaluator() {
return pipe( return mapPolicyToEvaluator(Policies.Password);
reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy),
distinctIfShallowMatch(),
map((policy) => new PasswordGeneratorOptionsEvaluator(policy)),
);
} }
/** {@link GeneratorStrategy.generate} */ // algorithm
generate(options: PasswordGenerationOptions): Promise<string> { async generate(options: PasswordGenerationOptions): Promise<string> {
return this.legacy.generatePassword({ ...options, type: "password" }); 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;
} }
} }

View File

@ -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<Policy, Evaluator> = {
/** 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<PassphraseGeneratorPolicy, PassphraseGeneratorOptionsEvaluator>);
const PASSWORD = Object.freeze({
disabledValue: DisabledPasswordGeneratorPolicy,
combine: passwordLeastPrivilege,
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGeneratorOptionsEvaluator>);
/** Policy configurations */
export const Policies = Object.freeze({
/** Passphrase policy configuration */
Passphrase: PASSPHRASE,
/** Passphrase policy configuration */
Password: PASSWORD,
});

View File

@ -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<Entry>(list: Array<Entry>) {
const index = await this.uniform(0, list.length - 1);
return list[index];
}
async pickWord(list: Array<string>, 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<T>(items: Array<T>, 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;
}
}

View File

@ -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, * An observable operator that reduces an emitted collection to a single object,
@ -36,3 +39,23 @@ export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
return isDistinct; 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<Policy, Evaluator>(
configuration: PolicyConfiguration<Policy, Evaluator>,
) {
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<Target>() {
return () => {
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
};
}

View File

@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions"; 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 SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({ const SomePolicy = mock<Policy>({
@ -40,8 +41,8 @@ describe("Email subaddress list generation strategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("should use password settings key", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>(); const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new CatchallGeneratorStrategy(legacy, provider); const strategy = new CatchallGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser); strategy.durableState(SomeUser);
@ -61,26 +62,14 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new CatchallGeneratorStrategy(legacy, null); const strategy = new CatchallGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
}); });
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it.todo("generate catchall email addresses");
const legacy = mock<UsernameGenerationServiceAbstraction>();
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);
});
}); });
}); });

View File

@ -1,13 +1,11 @@
import { BehaviorSubject, map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions"; import { CATCHALL_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy"; import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options";
@ -19,34 +17,34 @@ export class CatchallGeneratorStrategy
* @param usernameService generates a catchall address for a domain * @param usernameService generates a catchall address for a domain
*/ */
constructor( constructor(
private usernameService: UsernameGenerationServiceAbstraction, private random: Randomizer,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions,
) {} ) {}
/** {@link GeneratorStrategy.durableState} */ // configuration
durableState(id: UserId) { durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
return this.stateProvider.getUser(id, CATCHALL_SETTINGS); defaults$ = clone$PerUserId(this.defaultOptions);
} toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator;
/** {@link GeneratorStrategy.defaults$} */ // algorithm
defaults$(userId: UserId) { async generate(options: CatchallGenerationOptions) {
return new BehaviorSubject({ ...DefaultCatchallOptions }).asObservable(); const o = Object.assign({}, DefaultCatchallOptions, options);
}
/** {@link GeneratorStrategy.policy} */ if (o.catchallDomain == null || o.catchallDomain === "") {
get policy() { return null;
// Uses password generator since there aren't policies }
// specific to usernames. if (o.catchallType == null) {
return PolicyType.PasswordGenerator; o.catchallType = "random";
} }
/** {@link GeneratorStrategy.toEvaluator} */ let startString = "";
toEvaluator() { if (o.catchallType === "random") {
return pipe(map((_) => new DefaultPolicyEvaluator<CatchallGenerationOptions>())); startString = await this.random.chars(8);
} } else if (o.catchallType === "website-name") {
startString = o.website;
/** {@link GeneratorStrategy.generate} */ }
generate(options: CatchallGenerationOptions) { return startString + "@" + o.catchallDomain;
return this.usernameService.generateCatchall(options);
} }
} }

View File

@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { DefaultEffUsernameOptions } from "./eff-username-generator-options"; import { DefaultEffUsernameOptions } from "./eff-username-generator-options";
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; import { EffUsernameGeneratorStrategy } from ".";
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({ const SomePolicy = mock<Policy>({
@ -40,8 +41,8 @@ describe("EFF long word list generation strategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("should use password settings key", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>(); const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new EffUsernameGeneratorStrategy(legacy, provider); const strategy = new EffUsernameGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser); strategy.durableState(SomeUser);
@ -61,26 +62,14 @@ describe("EFF long word list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new EffUsernameGeneratorStrategy(legacy, null); const strategy = new EffUsernameGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
}); });
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it.todo("generate username tests");
const legacy = mock<UsernameGenerationServiceAbstraction>();
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);
});
}); });
}); });

View File

@ -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 { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy"; import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { import {
DefaultEffUsernameOptions, DefaultEffUsernameOptions,
@ -22,34 +22,23 @@ export class EffUsernameGeneratorStrategy
* @param usernameService generates a username from EFF word list * @param usernameService generates a username from EFF word list
*/ */
constructor( constructor(
private usernameService: UsernameGenerationServiceAbstraction, private random: Randomizer,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions,
) {} ) {}
/** {@link GeneratorStrategy.durableState} */ // configuration
durableState(id: UserId) { durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS); defaults$ = clone$PerUserId(this.defaultOptions);
} toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator;
/** {@link GeneratorStrategy.defaults$} */ // algorithm
defaults$(userId: UserId) { async generate(options: EffUsernameGenerationOptions) {
return new BehaviorSubject({ ...DefaultEffUsernameOptions }).asObservable(); const word = await this.random.pickWord(EFFLongWordList, {
} titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize,
number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber,
/** {@link GeneratorStrategy.policy} */ });
get policy() { return word;
// Uses password generator since there aren't policies
// specific to usernames.
return PolicyType.PasswordGenerator;
}
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() {
return pipe(map((_) => new DefaultPolicyEvaluator<EffUsernameGenerationOptions>()));
}
/** {@link GeneratorStrategy.generate} */
generate(options: EffUsernameGenerationOptions) {
return this.usernameService.generateWord(options);
} }
} }

View File

@ -25,7 +25,7 @@ class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, { website: null, token: "" });
} }
get key() { get key() {

View File

@ -1,4 +1,4 @@
import { Observable, map, pipe } from "rxjs"; import { map } from "rxjs";
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { CryptoService } from "../../../platform/abstractions/crypto.service"; 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 { SecretState } from "../../state/secret-state";
import { UserKeyEncryptor } from "../../state/user-key-encryptor"; import { UserKeyEncryptor } from "../../state/user-key-encryptor";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { NoPolicy } from "../no-policy"; import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedByUserId } from "../util";
import { ApiOptions } from "./options/forwarder-options"; import { ApiOptions } from "./options/forwarder-options";
@ -33,29 +34,25 @@ export abstract class ForwarderGeneratorStrategy<
private readonly encryptService: EncryptService, private readonly encryptService: EncryptService,
private readonly keyService: CryptoService, private readonly keyService: CryptoService,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private readonly defaultOptions: Options,
) { ) {
super(); super();
// Uses password generator since there aren't policies
// specific to usernames.
this.policy = PolicyType.PasswordGenerator;
} }
private durableStates = new Map<UserId, SingleUserState<Options>>(); /** configures forwarder secret storage */
protected abstract readonly key: UserKeyDefinition<Options>;
/** {@link GeneratorStrategy.durableState} */ /** configures forwarder import buffer */
durableState = (userId: UserId) => { protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
let state = this.durableStates.get(userId);
if (!state) { // configuration
state = this.createState(userId); readonly policy = PolicyType.PasswordGenerator;
defaults$ = clone$PerUserId(this.defaultOptions);
toEvaluator = newDefaultEvaluator<Options>();
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
this.durableStates.set(userId, state); // per-user encrypted state
} private getUserSecrets(userId: UserId): SingleUserState<Options> {
return state;
};
private createState(userId: UserId): SingleUserState<Options> {
// construct the encryptor // construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
@ -92,18 +89,4 @@ export abstract class ForwarderGeneratorStrategy<
return rolloverState; return rolloverState;
} }
/** Gets the default options. */
abstract defaults$: (userId: UserId) => Observable<Options>;
/** Determine where forwarder configuration is stored */
protected abstract readonly key: UserKeyDefinition<Options>;
/** Determine where forwarder rollover configuration is stored */
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator = () => {
return pipe(map((_) => new DefaultPolicyEvaluator<Options>()));
};
} }

View File

@ -1,11 +1,8 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions"; import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -36,25 +33,14 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy<
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = ADDY_IO_FORWARDER;
return ADDY_IO_FORWARDER; readonly rolloverKey = ADDY_IO_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.rolloverKey} */ // request
get rolloverKey() {
return ADDY_IO_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultAddyIoOptions });
};
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => { generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);

View File

@ -1,11 +1,8 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions"; import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -32,25 +29,14 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions>
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = DUCK_DUCK_GO_FORWARDER;
return DUCK_DUCK_GO_FORWARDER; readonly rolloverKey = DUCK_DUCK_GO_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.rolloverKey} */ // request
get rolloverKey() {
return DUCK_DUCK_GO_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultDuckDuckGoOptions });
};
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions): Promise<string> => { generate = async (options: ApiOptions): Promise<string> => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);

View File

@ -1,11 +1,8 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions"; import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -34,25 +31,14 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & E
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultFastmailOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = FASTMAIL_FORWARDER;
return FASTMAIL_FORWARDER; readonly rolloverKey = FASTMAIL_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */ // request
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultFastmailOptions });
};
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
get rolloverKey() {
return FASTMAIL_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions & EmailPrefixOptions) => { generate = async (options: ApiOptions & EmailPrefixOptions) => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
@ -76,7 +62,7 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & E
"new-masked-email": { "new-masked-email": {
state: "enabled", state: "enabled",
description: "", description: "",
forDomain: options.website, forDomain: options.website ?? "",
emailPrefix: options.prefix, emailPrefix: options.prefix,
}, },
}, },

View File

@ -1,11 +1,8 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions"; import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -32,25 +29,14 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = FIREFOX_RELAY_FORWARDER;
return FIREFOX_RELAY_FORWARDER; readonly rolloverKey = FIREFOX_RELAY_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.rolloverKey} */ // request
get rolloverKey() {
return FIREFOX_RELAY_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultFirefoxRelayOptions });
};
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions) => { generate = async (options: ApiOptions) => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);

View File

@ -1,12 +1,9 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { Utils } from "../../../../platform/misc/utils"; import { Utils } from "../../../../platform/misc/utils";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions"; import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -36,25 +33,14 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = FORWARD_EMAIL_FORWARDER;
return FORWARD_EMAIL_FORWARDER; readonly rolloverKey = FORWARD_EMAIL_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */ // request
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultForwardEmailOptions });
};
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
get rolloverKey() {
return FORWARD_EMAIL_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions & EmailDomainOptions) => { generate = async (options: ApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);

View File

@ -1,11 +1,8 @@
import { BehaviorSubject } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service"; import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state"; import { StateProvider } from "../../../../platform/state";
import { UserId } from "../../../../types/guid";
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions"; import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants"; import { Forwarders } from "../options/constants";
@ -33,25 +30,14 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedA
keyService: CryptoService, keyService: CryptoService,
stateProvider: StateProvider, stateProvider: StateProvider,
) { ) {
super(encryptService, keyService, stateProvider); super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions);
} }
/** {@link ForwarderGeneratorStrategy.key} */ // configuration
get key() { readonly key = SIMPLE_LOGIN_FORWARDER;
return SIMPLE_LOGIN_FORWARDER; readonly rolloverKey = SIMPLE_LOGIN_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.rolloverKey} */ // request
get rolloverKey() {
return SIMPLE_LOGIN_BUFFER;
}
/** {@link ForwarderGeneratorStrategy.defaults$} */
defaults$ = (userId: UserId) => {
return new BehaviorSubject({ ...DefaultSimpleLoginOptions });
};
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: SelfHostedApiOptions) => { generate = async (options: SelfHostedApiOptions) => {
if (!options.token || options.token === "") { if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);

View File

@ -3,4 +3,3 @@ export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
export { UsernameGeneratorOptions } from "./username-generation-options"; export { UsernameGeneratorOptions } from "./username-generation-options";
export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";
export { UsernameGenerationService } from "./username-generation.service";

View File

@ -7,15 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { import { DefaultSubaddressOptions } from "./subaddress-generator-options";
DefaultSubaddressOptions,
SubaddressGenerationOptions,
} from "./subaddress-generator-options";
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; import { SubaddressGeneratorStrategy } from ".";
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({ const SomePolicy = mock<Policy>({
@ -43,8 +41,8 @@ describe("Email subaddress list generation strategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("should use password settings key", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>(); const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new SubaddressGeneratorStrategy(legacy, provider); const strategy = new SubaddressGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser); strategy.durableState(SomeUser);
@ -64,26 +62,14 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const randomizer = mock<Randomizer>();
const strategy = new SubaddressGeneratorStrategy(legacy, null); const strategy = new SubaddressGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
}); });
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it.todo("generate email subaddress tests");
const legacy = mock<UsernameGenerationServiceAbstraction>();
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);
});
}); });
}); });

View File

@ -1,13 +1,11 @@
import { BehaviorSubject, map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state"; import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy"; import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { import {
DefaultSubaddressOptions, DefaultSubaddressOptions,
@ -26,34 +24,42 @@ export class SubaddressGeneratorStrategy
* @param usernameService generates an email subaddress from an email address * @param usernameService generates an email subaddress from an email address
*/ */
constructor( constructor(
private usernameService: UsernameGenerationServiceAbstraction, private random: Randomizer,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions,
) {} ) {}
/** {@link GeneratorStrategy.durableState} */ // configuration
durableState(id: UserId) { durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS); defaults$ = clone$PerUserId(this.defaultOptions);
} toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator;
/** {@link GeneratorStrategy.defaults$} */ // algorithm
defaults$(userId: UserId) { async generate(options: SubaddressGenerationOptions) {
return new BehaviorSubject({ ...DefaultSubaddressOptions }).asObservable(); const o = Object.assign({}, DefaultSubaddressOptions, options);
}
/** {@link GeneratorStrategy.policy} */ const subaddressEmail = o.subaddressEmail;
get policy() { if (subaddressEmail == null || subaddressEmail.length < 3) {
// Uses password generator since there aren't policies return o.subaddressEmail;
// specific to usernames. }
return PolicyType.PasswordGenerator; const atIndex = subaddressEmail.indexOf("@");
} if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) {
return subaddressEmail;
}
if (o.subaddressType == null) {
o.subaddressType = "random";
}
/** {@link GeneratorStrategy.toEvaluator} */ const emailBeginning = subaddressEmail.substr(0, atIndex);
toEvaluator() { const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length);
return pipe(map((_) => new DefaultPolicyEvaluator<SubaddressGenerationOptions>()));
}
/** {@link GeneratorStrategy.generate} */ let subaddressString = "";
generate(options: SubaddressGenerationOptions) { if (o.subaddressType === "random") {
return this.usernameService.generateSubaddress(options); subaddressString = await this.random.chars(8);
} else if (o.subaddressType === "website-name") {
subaddressString = o.website;
}
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
} }
} }

View File

@ -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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<UsernameGeneratorOptions> {
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;
}
}

View File

@ -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<Value>(defaultValue: Value) {
const _subjects = new Map<UserId, BehaviorSubject<Value>>();
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<Value>(create: (userId: UserId) => SingleUserState<Value>) {
const _subjects = new Map<UserId, SingleUserState<Value>>();
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<Value>(key: UserKeyDefinition<Value>, provider: StateProvider) {
return (id: UserId) => provider.getUser<Value>(id, key);
}

View File

@ -0,0 +1,6 @@
export type WordOptions = {
/** set the first letter uppercase */
titleCase?: boolean;
/** append a number */
number?: boolean;
};

View File

@ -6,7 +6,7 @@ import {
awaitAsync, awaitAsync,
trackEmissions, trackEmissions,
} from "../../../spec"; } from "../../../spec";
import { GENERATOR_DISK, KeyDefinition } from "../../platform/state"; import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { BufferedKeyDefinition } from "./buffered-key-definition"; import { BufferedKeyDefinition } from "./buffered-key-definition";
@ -16,8 +16,9 @@ const SomeUser = "SomeUser" as UserId;
const accountService = mockAccountServiceWith(SomeUser); const accountService = mockAccountServiceWith(SomeUser);
type SomeType = { foo: boolean; bar: boolean }; type SomeType = { foo: boolean; bar: boolean };
const SOME_KEY = new KeyDefinition<SomeType>(GENERATOR_DISK, "fooBar", { const SOME_KEY = new UserKeyDefinition<SomeType>(GENERATOR_DISK, "fooBar", {
deserializer: (jsonValue) => jsonValue as SomeType, deserializer: (jsonValue) => jsonValue as SomeType,
clearOn: [],
}); });
const BUFFER_KEY = new BufferedKeyDefinition<SomeType>(GENERATOR_DISK, "fooBar_buffer", { const BUFFER_KEY = new BufferedKeyDefinition<SomeType>(GENERATOR_DISK, "fooBar_buffer", {
deserializer: (jsonValue) => jsonValue as SomeType, deserializer: (jsonValue) => jsonValue as SomeType,

View File

@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService { export abstract class CipherService {
cipherViews$: Observable<Record<CipherId, CipherView>>; cipherViews$: Observable<Record<CipherId, CipherView>>;
ciphers$: Observable<Record<CipherId, CipherData>>;
/** /**
* An observable monitoring the add/edit cipher info saved to memory. * An observable monitoring the add/edit cipher info saved to memory.
*/ */

View File

@ -142,6 +142,17 @@ export class IdentityView extends ItemView {
return addressPart2; 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<Jsonify<IdentityView>>): IdentityView { static fromJSON(obj: Partial<Jsonify<IdentityView>>): IdentityView {
return Object.assign(new IdentityView(), obj); return Object.assign(new IdentityView(), obj);
} }

View File

@ -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 { SemVer } from "semver";
import { ApiService } from "../../abstractions/api.service"; import { ApiService } from "../../abstractions/api.service";
@ -125,13 +125,7 @@ export class CipherService implements CipherServiceAbstraction {
switchMap(() => this.encryptedCiphersState.state$), switchMap(() => this.encryptedCiphersState.state$),
map((ciphers) => ciphers ?? {}), map((ciphers) => ciphers ?? {}),
); );
this.cipherViews$ = this.decryptedCiphersState.state$.pipe( this.cipherViews$ = this.decryptedCiphersState.state$.pipe(map((views) => views ?? {}));
map((views) => views ?? {}),
share({
resetOnRefCountZero: true,
}),
);
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$; this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
} }

View File

@ -49,7 +49,7 @@ export class BadgeDirective implements FocusableElement {
] ]
.concat(styles[this.variant]) .concat(styles[this.variant])
.concat(this.hasHoverEffects ? hoverStyles[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() { @HostBinding("attr.title") get title() {
return this.truncate ? this.el.nativeElement.textContent.trim() : null; return this.truncate ? this.el.nativeElement.textContent.trim() : null;
@ -65,6 +65,8 @@ export class BadgeDirective implements FocusableElement {
*/ */
@Input() truncate = true; @Input() truncate = true;
@Input() maxWidthClass: `tw-max-w-${string}` = "tw-max-w-40";
getFocusTarget() { getFocusTarget() {
return this.el.nativeElement; return this.el.nativeElement;
} }

View File

@ -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
* <button appCopyField="username" [cipher]="cipher">Copy Username</button>
* ```
*/
@Directive({
standalone: true,
selector: "[appCopyField]",
})
export class CopyCipherFieldDirective implements OnChanges {
@Input({
alias: "appCopyField",
required: true,
})
action: Exclude<CopyAction, "hiddenField">;
@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;
}
}
}

View File

@ -1 +1,3 @@
export { PasswordRepromptService } from "./services/password-reprompt.service"; export { PasswordRepromptService } from "./services/password-reprompt.service";
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";

View File

@ -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<PlatformUtilsService>;
let toastService: MockProxy<ToastService>;
let eventCollectionService: MockProxy<EventCollectionService>;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let totpService: MockProxy<TotpService>;
let i18nService: MockProxy<I18nService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
beforeEach(() => {
platformUtilsService = mock<PlatformUtilsService>();
toastService = mock<ToastService>();
eventCollectionService = mock<EventCollectionService>();
passwordRepromptService = mock<PasswordRepromptService>();
totpService = mock<TotpService>();
i18nService = mock<I18nService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
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<CipherView>();
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,
);
});
});
});

View File

@ -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<CopyAction, CopyActionInfo> = {
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<boolean> {
return (
(cipher?.login?.hasTotp ?? false) &&
(cipher.organizationUseTotp ||
(await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$)))
);
}
}