mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-24 21:41:33 +01:00
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:
commit
02bbb41148
@ -185,7 +185,7 @@
|
||||
"message": "Continue to browser extension store?"
|
||||
},
|
||||
"continueToBrowserExtensionStoreDesc": {
|
||||
"message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now."
|
||||
"message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now."
|
||||
},
|
||||
"changeMasterPasswordOnWebConfirmation": {
|
||||
"message": "You can change your master password on the Bitwarden web app."
|
||||
@ -389,6 +389,9 @@
|
||||
"favorite": {
|
||||
"message": "Favorite"
|
||||
},
|
||||
"unfavorite": {
|
||||
"message": "Unfavorite"
|
||||
},
|
||||
"notes": {
|
||||
"message": "Notes"
|
||||
},
|
||||
@ -410,6 +413,9 @@
|
||||
"launch": {
|
||||
"message": "Launch"
|
||||
},
|
||||
"launchWebsite": {
|
||||
"message": "Launch website"
|
||||
},
|
||||
"website": {
|
||||
"message": "Website"
|
||||
},
|
||||
@ -822,7 +828,7 @@
|
||||
},
|
||||
"exportPasswordDescription": {
|
||||
"message": "This password will be used to export and import this file"
|
||||
},
|
||||
},
|
||||
"accountRestrictedOptionDescription": {
|
||||
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
|
||||
},
|
||||
@ -1431,6 +1437,15 @@
|
||||
"collections": {
|
||||
"message": "Collections"
|
||||
},
|
||||
"nCollections": {
|
||||
"message": "$COUNT$ collections",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"message": "Favorites"
|
||||
},
|
||||
@ -1660,6 +1675,9 @@
|
||||
"autoFillAndSave": {
|
||||
"message": "Auto-fill and save"
|
||||
},
|
||||
"fillAndSave": {
|
||||
"message": "Fill and save"
|
||||
},
|
||||
"autoFillSuccessAndSavedUri": {
|
||||
"message": "Item auto-filled and URI saved"
|
||||
},
|
||||
@ -3281,7 +3299,7 @@
|
||||
"clearFiltersOrTryAnother": {
|
||||
"message": "Clear filters or try another search term"
|
||||
},
|
||||
"copyInfo": {
|
||||
"copyInfoLabel": {
|
||||
"message": "Copy info, $ITEMNAME$",
|
||||
"description": "Aria label for a button that opens a menu with options to copy information from an item.",
|
||||
"placeholders": {
|
||||
@ -3291,7 +3309,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"moreOptions": {
|
||||
"copyInfoTitle": {
|
||||
"message": "Copy info - $ITEMNAME$",
|
||||
"description": "Title for a button that opens a menu with options to copy information from an item.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"copyNoteLabel": {
|
||||
"message": "Copy Note, $ITEMNAME$",
|
||||
"description": "Aria label for a button copies a note to the clipboard.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Note Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"copyNoteTitle": {
|
||||
"message": "Copy Note - $ITEMNAME$",
|
||||
"description": "Title for a button copies a note to the clipboard.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Note Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moreOptionsLabel": {
|
||||
"message": "More options, $ITEMNAME$",
|
||||
"description": "Aria label for a button that opens a menu with more options for an item.",
|
||||
"placeholders": {
|
||||
@ -3301,6 +3349,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"moreOptionsTitle": {
|
||||
"message": "More options - $ITEMNAME$",
|
||||
"description": "Title for a button that opens a menu with more options for an item.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewItemTitle": {
|
||||
"message": "View item - $ITEMNAME$",
|
||||
"description": "Title for a link that opens a view for an item.",
|
||||
"placeholders": {
|
||||
"itemname": {
|
||||
"content": "$1",
|
||||
"example": "Secret Item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assignCollections": {
|
||||
"message": "Assign collections"
|
||||
},
|
||||
"copyEmail": {
|
||||
"message": "Copy email"
|
||||
},
|
||||
"copyPhone": {
|
||||
"message": "Copy phone"
|
||||
},
|
||||
"copyAddress": {
|
||||
"message": "Copy address"
|
||||
},
|
||||
"adminConsole": {
|
||||
"message": "Admin Console"
|
||||
},
|
||||
|
@ -4,7 +4,7 @@
|
||||
[title]="'autofillSuggestions' | i18n"
|
||||
[showRefresh]="showRefresh"
|
||||
(onRefresh)="refreshCurrentTab()"
|
||||
showAutoFill
|
||||
showAutofillButton
|
||||
></app-vault-list-items-container>
|
||||
<ng-container *ngIf="showEmptyAutofillTip$ | async">
|
||||
<bit-section>
|
||||
|
@ -3,12 +3,12 @@ import { Component } from "@angular/core";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
||||
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
|
||||
|
||||
@Component({
|
||||
@ -30,7 +30,7 @@ export class AutofillVaultListItemsComponent {
|
||||
* The list of ciphers that can be used to autofill the current page.
|
||||
* @protected
|
||||
*/
|
||||
protected autofillCiphers$: Observable<CipherView[]> =
|
||||
protected autofillCiphers$: Observable<PopupCipherView[]> =
|
||||
this.vaultPopupItemsService.autoFillCiphers$;
|
||||
|
||||
/**
|
||||
|
@ -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>
|
@ -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() {}
|
||||
}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -13,31 +13,31 @@
|
||||
</popup-section-header>
|
||||
<bit-item-group>
|
||||
<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>
|
||||
{{ cipher.name }}
|
||||
<i
|
||||
class="bwi bwi-sm"
|
||||
*ngIf="cipher.organizationId"
|
||||
[ngClass]="cipher.orgIcon"
|
||||
[appA11yTitle]="orgIconTooltip(cipher)"
|
||||
></i>
|
||||
<span slot="secondary">{{ cipher.subTitle }}</span>
|
||||
</a>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action *ngIf="showAutoFill">
|
||||
<bit-item-action *ngIf="showAutofillButton">
|
||||
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[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>
|
||||
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
|
||||
<app-item-more-options
|
||||
[cipher]="cipher"
|
||||
[hideLoginOptions]="showAutofillButton"
|
||||
></app-item-more-options>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
|
@ -3,7 +3,7 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul
|
||||
import { RouterLink } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
@ -14,6 +14,9 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
|
||||
import { PopupCipherView } from "../../../views/popup-cipher.view";
|
||||
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
|
||||
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
@ -27,6 +30,8 @@ import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup
|
||||
JslibModule,
|
||||
PopupSectionHeaderComponent,
|
||||
RouterLink,
|
||||
ItemCopyActionsComponent,
|
||||
ItemMoreOptionsComponent,
|
||||
],
|
||||
selector: "app-vault-list-items-container",
|
||||
templateUrl: "vault-list-items-container.component.html",
|
||||
@ -37,7 +42,7 @@ export class VaultListItemsContainerComponent {
|
||||
* The list of ciphers to display.
|
||||
*/
|
||||
@Input()
|
||||
ciphers: CipherView[];
|
||||
ciphers: PopupCipherView[] = [];
|
||||
|
||||
/**
|
||||
* Title for the vault list item section.
|
||||
@ -61,5 +66,19 @@ export class VaultListItemsContainerComponent {
|
||||
* Option to show the autofill button for each item.
|
||||
*/
|
||||
@Input({ transform: booleanAttribute })
|
||||
showAutoFill: boolean;
|
||||
showAutofillButton: boolean;
|
||||
|
||||
/**
|
||||
* The tooltip text for the organization icon for ciphers that belong to an organization.
|
||||
* @param cipher
|
||||
*/
|
||||
orgIconTooltip(cipher: PopupCipherView) {
|
||||
if (cipher.collectionIds.length > 1) {
|
||||
return this.i18nService.t("nCollections", cipher.collectionIds.length);
|
||||
}
|
||||
|
||||
return cipher.collections[0]?.name;
|
||||
}
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
}
|
||||
|
@ -22,10 +22,12 @@
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!(showEmptyState$ | async)">
|
||||
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
|
||||
</app-vault-v2-search>
|
||||
<div class="tw-fixed">
|
||||
<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
|
||||
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"
|
||||
|
@ -1,13 +1,18 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
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";
|
||||
|
||||
describe("VaultPopupItemsService", () => {
|
||||
let testBed: TestBed;
|
||||
let service: VaultPopupItemsService;
|
||||
let allCiphers: Record<CipherId, CipherView>;
|
||||
let autoFillCiphers: CipherView[];
|
||||
|
||||
let mockOrg: Organization;
|
||||
let mockCollections: CollectionView[];
|
||||
|
||||
const cipherServiceMock = mock<CipherService>();
|
||||
const vaultSettingsServiceMock = mock<VaultSettingsService>();
|
||||
const organizationServiceMock = mock<OrganizationService>();
|
||||
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
|
||||
const searchService = mock<SearchService>();
|
||||
const collectionService = mock<CollectionService>();
|
||||
|
||||
beforeEach(() => {
|
||||
allCiphers = cipherFactory(10);
|
||||
@ -39,11 +49,14 @@ describe("VaultPopupItemsService", () => {
|
||||
cipherList[2].favorite = true;
|
||||
cipherList[3].favorite = true;
|
||||
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable();
|
||||
searchService.searchCiphers.mockImplementation(async () => cipherList);
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
|
||||
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
|
||||
cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable();
|
||||
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
|
||||
ciphers.filter((c) => ["0", "1"].includes(c.id)),
|
||||
);
|
||||
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false);
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false);
|
||||
|
||||
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
|
||||
organization: null,
|
||||
@ -55,31 +68,61 @@ describe("VaultPopupItemsService", () => {
|
||||
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
|
||||
(ciphers: CipherView[]) => ciphers,
|
||||
);
|
||||
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(BrowserApi, "getTabFromCurrentWindow")
|
||||
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
vaultPopupListFiltersServiceMock,
|
||||
organizationServiceMock,
|
||||
searchService,
|
||||
);
|
||||
|
||||
mockOrg = {
|
||||
id: "org1",
|
||||
name: "Organization 1",
|
||||
planProductType: ProductType.Enterprise,
|
||||
} as Organization;
|
||||
|
||||
mockCollections = [
|
||||
{ id: "col1", name: "Collection 1" } as CollectionView,
|
||||
{ id: "col2", name: "Collection 2" } as CollectionView,
|
||||
];
|
||||
|
||||
organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]);
|
||||
collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections);
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: CipherService, useValue: cipherServiceMock },
|
||||
{ provide: VaultSettingsService, useValue: vaultSettingsServiceMock },
|
||||
{ provide: SearchService, useValue: searchService },
|
||||
{ provide: OrganizationService, useValue: organizationServiceMock },
|
||||
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
|
||||
{ provide: CollectionService, useValue: collectionService },
|
||||
],
|
||||
});
|
||||
|
||||
service = testBed.inject(VaultPopupItemsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
vaultPopupListFiltersServiceMock,
|
||||
organizationServiceMock,
|
||||
searchService,
|
||||
);
|
||||
service = testBed.inject(VaultPopupItemsService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should merge cipher views with collections and organization", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
cipherList[0].organizationId = "org1";
|
||||
cipherList[0].collectionIds = ["col1", "col2"];
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers[0].organization).toEqual(mockOrg);
|
||||
expect(ciphers[0].collections).toContain(mockCollections[0]);
|
||||
expect(ciphers[0].collections).toContain(mockCollections[1]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoFillCiphers$", () => {
|
||||
it("should return empty array if there is no current tab", (done) => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
|
||||
@ -100,18 +143,10 @@ describe("VaultPopupItemsService", () => {
|
||||
it("should filter ciphers for the current tab and types", (done) => {
|
||||
const currentTab = { url: "https://example.com" } as chrome.tabs.Tab;
|
||||
|
||||
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable();
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable();
|
||||
(vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject<boolean>).next(true);
|
||||
(vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject<boolean>).next(true);
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
|
||||
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
vaultPopupListFiltersServiceMock,
|
||||
organizationServiceMock,
|
||||
searchService,
|
||||
);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
|
||||
expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith(
|
||||
@ -136,14 +171,6 @@ describe("VaultPopupItemsService", () => {
|
||||
Object.values(allCiphers),
|
||||
);
|
||||
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
vaultPopupListFiltersServiceMock,
|
||||
organizationServiceMock,
|
||||
searchService,
|
||||
);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers.length).toBe(10);
|
||||
|
||||
@ -159,19 +186,18 @@ describe("VaultPopupItemsService", () => {
|
||||
});
|
||||
|
||||
it("should filter autoFillCiphers$ down to search term", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
const searchText = "Login";
|
||||
|
||||
searchService.searchCiphers.mockImplementation(async () => {
|
||||
return cipherList.filter((cipher) => {
|
||||
searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => {
|
||||
return ciphers.filter((cipher) => {
|
||||
return cipher.name.includes(searchText);
|
||||
});
|
||||
});
|
||||
|
||||
// there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types
|
||||
// there is only 1 Login returned for filteredCiphers.
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers[0].name.includes(searchText)).toBe(true);
|
||||
expect(ciphers.length).toBe(2);
|
||||
expect(ciphers.length).toBe(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -248,14 +274,7 @@ describe("VaultPopupItemsService", () => {
|
||||
|
||||
describe("emptyVault$", () => {
|
||||
it("should return true if there are no ciphers", (done) => {
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable();
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
vaultPopupListFiltersServiceMock,
|
||||
organizationServiceMock,
|
||||
searchService,
|
||||
);
|
||||
cipherServiceMock.getAllDecrypted.mockResolvedValue([]);
|
||||
service.emptyVault$.subscribe((empty) => {
|
||||
expect(empty).toBe(true);
|
||||
done();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { inject, Injectable, NgZone } from "@angular/core";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
@ -15,13 +15,18 @@ import {
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { PopupCipherView } from "../views/popup-cipher.view";
|
||||
|
||||
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
|
||||
@ -72,12 +77,33 @@ export class VaultPopupItemsService {
|
||||
* Observable that contains the list of all decrypted ciphers.
|
||||
* @private
|
||||
*/
|
||||
private _cipherList$: Observable<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)),
|
||||
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.searchText$,
|
||||
this.vaultPopupListFiltersService.filterFunction$,
|
||||
@ -86,8 +112,9 @@ export class VaultPopupItemsService {
|
||||
filterFunction(ciphers),
|
||||
searchText,
|
||||
]),
|
||||
switchMap(([ciphers, searchText]) =>
|
||||
this.searchService.searchCiphers(searchText, null, ciphers),
|
||||
switchMap(
|
||||
([ciphers, searchText]) =>
|
||||
this.searchService.searchCiphers(searchText, null, ciphers) as Promise<PopupCipherView[]>,
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
@ -98,7 +125,7 @@ export class VaultPopupItemsService {
|
||||
*
|
||||
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
|
||||
*/
|
||||
autoFillCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([
|
||||
this._filteredCipherList$,
|
||||
this._otherAutoFillTypes$,
|
||||
this._currentAutofillTab$,
|
||||
@ -117,7 +144,7 @@ export class VaultPopupItemsService {
|
||||
* List of favorite ciphers that are not currently suggested for autofill.
|
||||
* Ciphers are sorted by last used date, then by name.
|
||||
*/
|
||||
favoriteCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
favoriteCiphers$: Observable<PopupCipherView[]> = combineLatest([
|
||||
this.autoFillCiphers$,
|
||||
this._filteredCipherList$,
|
||||
]).pipe(
|
||||
@ -134,7 +161,7 @@ export class VaultPopupItemsService {
|
||||
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
|
||||
* Ciphers are sorted by name.
|
||||
*/
|
||||
remainingCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
remainingCiphers$: Observable<PopupCipherView[]> = combineLatest([
|
||||
this.autoFillCiphers$,
|
||||
this.favoriteCiphers$,
|
||||
this._filteredCipherList$,
|
||||
@ -204,6 +231,7 @@ export class VaultPopupItemsService {
|
||||
private vaultPopupListFiltersService: VaultPopupListFiltersService,
|
||||
private organizationService: OrganizationService,
|
||||
private searchService: SearchService,
|
||||
private collectionService: CollectionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -169,6 +169,13 @@ describe("VaultPopupListFiltersService", () => {
|
||||
expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets collection icon", (done) => {
|
||||
service.collections$.subscribe((collections) => {
|
||||
expect(collections.every(({ icon }) => icon === "bwi-collection")).toBeTruthy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("folders$", () => {
|
||||
@ -210,6 +217,22 @@ describe("VaultPopupListFiltersService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets folder icon", (done) => {
|
||||
service.filterForm.patchValue({
|
||||
organization: { id: MY_VAULT_ID } as Organization,
|
||||
});
|
||||
|
||||
folderViews$.next([
|
||||
{ id: "1234", name: "Folder 1" },
|
||||
{ id: "2345", name: "Folder 2" },
|
||||
]);
|
||||
|
||||
service.folders$.subscribe((folders) => {
|
||||
expect(folders.every(({ icon }) => icon === "bwi-folder")).toBeTruthy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns folders that have ciphers within the selected organization", (done) => {
|
||||
service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => {
|
||||
expect(folders.map((f) => f.label)).toEqual(["Folder 1"]);
|
||||
|
@ -206,7 +206,7 @@ export class VaultPopupListFiltersService {
|
||||
/**
|
||||
* Folder array structured to be directly passed to `ChipSelectComponent`
|
||||
*/
|
||||
folders$: Observable<ChipSelectOption<string>[]> = combineLatest([
|
||||
folders$: Observable<ChipSelectOption<FolderView>[]> = combineLatest([
|
||||
this.filters$.pipe(
|
||||
distinctUntilChanged(
|
||||
(previousFilter, currentFilter) =>
|
||||
@ -258,13 +258,15 @@ export class VaultPopupListFiltersService {
|
||||
nestedList: nestedFolders,
|
||||
});
|
||||
}),
|
||||
map((folders) => folders.nestedList.map(this.convertToChipSelectOption.bind(this))),
|
||||
map((folders) =>
|
||||
folders.nestedList.map((f) => this.convertToChipSelectOption(f, "bwi-folder")),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Collection array structured to be directly passed to `ChipSelectComponent`
|
||||
*/
|
||||
collections$: Observable<ChipSelectOption<string>[]> = combineLatest([
|
||||
collections$: Observable<ChipSelectOption<CollectionView>[]> = combineLatest([
|
||||
this.filters$.pipe(
|
||||
distinctUntilChanged(
|
||||
(previousFilter, currentFilter) =>
|
||||
@ -292,7 +294,9 @@ export class VaultPopupListFiltersService {
|
||||
nestedList: nestedCollections,
|
||||
});
|
||||
}),
|
||||
map((collections) => collections.nestedList.map(this.convertToChipSelectOption.bind(this))),
|
||||
map((collections) =>
|
||||
collections.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection")),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
@ -300,13 +304,14 @@ export class VaultPopupListFiltersService {
|
||||
*/
|
||||
private convertToChipSelectOption<T extends ITreeNodeObject>(
|
||||
item: TreeNode<T>,
|
||||
icon: string,
|
||||
): ChipSelectOption<T> {
|
||||
return {
|
||||
value: item.node,
|
||||
label: item.node.name,
|
||||
icon: "bwi-folder", // Organization & Folder icons are the same
|
||||
icon,
|
||||
children: item.children
|
||||
? item.children.map(this.convertToChipSelectOption.bind(this))
|
||||
? item.children.map((i) => this.convertToChipSelectOption(i, icon))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
41
apps/browser/src/vault/popup/views/popup-cipher.view.ts
Normal file
41
apps/browser/src/vault/popup/views/popup-cipher.view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -144,6 +144,7 @@
|
||||
</bit-form-control>
|
||||
<bit-form-control>
|
||||
<input
|
||||
id="manageUsers"
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
formControlName="manageUsers"
|
||||
@ -203,6 +204,7 @@
|
||||
</bit-form-control>
|
||||
<bit-form-control>
|
||||
<input
|
||||
id="manageUsers"
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
formControlName="manageUsers"
|
||||
|
@ -146,19 +146,17 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
}
|
||||
};
|
||||
|
||||
adjustStorage = (add: boolean) => {
|
||||
return async () => {
|
||||
const dialogRef = openAdjustStorageDialog(this.dialogService, {
|
||||
data: {
|
||||
storageGbPrice: 4,
|
||||
add: add,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AdjustStorageDialogResult.Adjusted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
adjustStorage = async (add: boolean) => {
|
||||
const dialogRef = openAdjustStorageDialog(this.dialogService, {
|
||||
data: {
|
||||
storageGbPrice: 4,
|
||||
add: add,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AdjustStorageDialogResult.Adjusted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
get subscriptionMarkedForCancel() {
|
||||
|
@ -12,6 +12,14 @@ import {
|
||||
GlobalState,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
/**
|
||||
* Data properties acceptable for use in route objects (see usage in oss-routing.module.ts for example)
|
||||
*/
|
||||
export interface DataProperties {
|
||||
titleId?: string; // sets the title of the current HTML document (shows in browser tab)
|
||||
doNotSaveUrl?: boolean; // choose to not keep track of the previous URL in memory
|
||||
}
|
||||
|
||||
const DEEP_LINK_REDIRECT_URL = new KeyDefinition(ROUTER_DISK, "deepLinkRedirectUrl", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
|
@ -40,6 +40,7 @@ import { UpdatePasswordComponent } from "./auth/update-password.component";
|
||||
import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component";
|
||||
import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component";
|
||||
import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component";
|
||||
import { DataProperties } from "./core";
|
||||
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "./layouts/user-layout.component";
|
||||
import { DomainRulesComponent } from "./settings/domain-rules.component";
|
||||
@ -54,7 +55,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
component: FrontendLayoutComponent,
|
||||
data: { doNotSaveUrl: true },
|
||||
data: { doNotSaveUrl: true } satisfies DataProperties,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
@ -66,17 +67,17 @@ const routes: Routes = [
|
||||
{
|
||||
path: "login-with-device",
|
||||
component: LoginViaAuthRequestComponent,
|
||||
data: { titleId: "loginWithDevice" },
|
||||
data: { titleId: "loginWithDevice" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "login-with-passkey",
|
||||
component: LoginViaWebAuthnComponent,
|
||||
data: { titleId: "loginWithPasskey" },
|
||||
data: { titleId: "loginWithPasskey" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "admin-approval-requested",
|
||||
component: LoginViaAuthRequestComponent,
|
||||
data: { titleId: "adminApprovalRequested" },
|
||||
data: { titleId: "adminApprovalRequested" } satisfies DataProperties,
|
||||
},
|
||||
{ path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] },
|
||||
{
|
||||
@ -88,7 +89,7 @@ const routes: Routes = [
|
||||
path: "register",
|
||||
component: TrialInitiationComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "createAccount" },
|
||||
data: { titleId: "createAccount" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "trial",
|
||||
@ -99,18 +100,18 @@ const routes: Routes = [
|
||||
path: "sso",
|
||||
component: SsoComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "enterpriseSingleSignOn" },
|
||||
data: { titleId: "enterpriseSingleSignOn" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "set-password",
|
||||
component: SetPasswordComponent,
|
||||
data: { titleId: "setMasterPassword" },
|
||||
data: { titleId: "setMasterPassword" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "hint",
|
||||
component: HintComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "passwordHint" },
|
||||
data: { titleId: "passwordHint" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "lock",
|
||||
@ -122,12 +123,12 @@ const routes: Routes = [
|
||||
path: "accept-organization",
|
||||
canActivate: [deepLinkGuard()],
|
||||
component: AcceptOrganizationComponent,
|
||||
data: { titleId: "joinOrganization", doNotSaveUrl: false },
|
||||
data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "accept-emergency",
|
||||
canActivate: [deepLinkGuard()],
|
||||
data: { titleId: "acceptEmergency", doNotSaveUrl: false },
|
||||
data: { titleId: "acceptEmergency", doNotSaveUrl: false } satisfies DataProperties,
|
||||
loadComponent: () =>
|
||||
import("./auth/emergency-access/accept/accept-emergency.component").then(
|
||||
(mod) => mod.AcceptEmergencyComponent,
|
||||
@ -137,26 +138,26 @@ const routes: Routes = [
|
||||
path: "accept-families-for-enterprise",
|
||||
component: AcceptFamilySponsorshipComponent,
|
||||
canActivate: [deepLinkGuard()],
|
||||
data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false },
|
||||
data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false } satisfies DataProperties,
|
||||
},
|
||||
{ path: "recover", pathMatch: "full", redirectTo: "recover-2fa" },
|
||||
{
|
||||
path: "recover-2fa",
|
||||
component: RecoverTwoFactorComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "recoverAccountTwoStep" },
|
||||
data: { titleId: "recoverAccountTwoStep" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "recover-delete",
|
||||
component: RecoverDeleteComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "deleteAccount" },
|
||||
data: { titleId: "deleteAccount" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "verify-recover-delete",
|
||||
component: VerifyRecoverDeleteComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "deleteAccount" },
|
||||
data: { titleId: "deleteAccount" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "verify-recover-delete-org",
|
||||
@ -168,30 +169,30 @@ const routes: Routes = [
|
||||
path: "verify-recover-delete-provider",
|
||||
component: VerifyRecoverDeleteProviderComponent,
|
||||
canActivate: [UnauthGuard],
|
||||
data: { titleId: "deleteAccount" },
|
||||
data: { titleId: "deleteAccount" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "send/:sendId/:key",
|
||||
component: AccessComponent,
|
||||
data: { title: "Bitwarden Send" },
|
||||
data: { titleId: "Bitwarden Send" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "update-temp-password",
|
||||
component: UpdateTempPasswordComponent,
|
||||
canActivate: [AuthGuard],
|
||||
data: { titleId: "updateTempPassword" },
|
||||
data: { titleId: "updateTempPassword" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "update-password",
|
||||
component: UpdatePasswordComponent,
|
||||
canActivate: [AuthGuard],
|
||||
data: { titleId: "updatePassword" },
|
||||
data: { titleId: "updatePassword" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "remove-password",
|
||||
component: RemovePasswordComponent,
|
||||
canActivate: [AuthGuard],
|
||||
data: { titleId: "removeMasterPassword" },
|
||||
data: { titleId: "removeMasterPassword" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "migrate-legacy-encryption",
|
||||
@ -211,21 +212,29 @@ const routes: Routes = [
|
||||
path: "vault",
|
||||
loadChildren: () => VaultModule,
|
||||
},
|
||||
{ path: "sends", component: SendComponent, data: { titleId: "send" } },
|
||||
{
|
||||
path: "sends",
|
||||
component: SendComponent,
|
||||
data: { titleId: "send" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "create-organization",
|
||||
component: CreateOrganizationComponent,
|
||||
data: { titleId: "newOrganization" },
|
||||
data: { titleId: "newOrganization" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
children: [
|
||||
{ path: "", pathMatch: "full", redirectTo: "account" },
|
||||
{ path: "account", component: AccountComponent, data: { titleId: "myAccount" } },
|
||||
{
|
||||
path: "account",
|
||||
component: AccountComponent,
|
||||
data: { titleId: "myAccount" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "preferences",
|
||||
component: PreferencesComponent,
|
||||
data: { titleId: "preferences" },
|
||||
data: { titleId: "preferences" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "security",
|
||||
@ -234,7 +243,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "domain-rules",
|
||||
component: DomainRulesComponent,
|
||||
data: { titleId: "domainRules" },
|
||||
data: { titleId: "domainRules" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "subscription",
|
||||
@ -249,19 +258,19 @@ const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
component: EmergencyAccessComponent,
|
||||
data: { titleId: "emergencyAccess" },
|
||||
data: { titleId: "emergencyAccess" } satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
component: EmergencyAccessViewComponent,
|
||||
data: { titleId: "emergencyAccess" },
|
||||
data: { titleId: "emergencyAccess" } satisfies DataProperties,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "sponsored-families",
|
||||
component: SponsoredFamiliesComponent,
|
||||
data: { titleId: "sponsoredFamilies" },
|
||||
data: { titleId: "sponsoredFamilies" } satisfies DataProperties,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -276,7 +285,7 @@ const routes: Routes = [
|
||||
import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent),
|
||||
data: {
|
||||
titleId: "importData",
|
||||
},
|
||||
} satisfies DataProperties,
|
||||
},
|
||||
{
|
||||
path: "export",
|
||||
@ -286,7 +295,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "generator",
|
||||
component: GeneratorComponent,
|
||||
data: { titleId: "generator" },
|
||||
data: { titleId: "generator" } satisfies DataProperties,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@ -94,6 +95,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private smOnboardingTasksService: SMOnboardingTasksService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@ -297,12 +299,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
|
||||
}
|
||||
|
||||
copySecretValue(id: string) {
|
||||
SecretsListComponent.copySecretValue(
|
||||
async copySecretValue(id: string) {
|
||||
await SecretsListComponent.copySecretValue(
|
||||
id,
|
||||
this.platformUtilsService,
|
||||
this.i18nService,
|
||||
this.secretService,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
@ -310,11 +313,9 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService);
|
||||
}
|
||||
|
||||
protected hideOnboarding() {
|
||||
protected async hideOnboarding() {
|
||||
this.showOnboarding = false;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.saveCompletedTasks(this.organizationId, {
|
||||
await this.saveCompletedTasks(this.organizationId, {
|
||||
importSecrets: true,
|
||||
createSecret: true,
|
||||
createProject: true,
|
||||
|
@ -82,9 +82,7 @@ export class ProjectDialogComponent implements OnInit {
|
||||
const projectView = this.getProjectView();
|
||||
if (this.data.operation === OperationType.Add) {
|
||||
const newProject = await this.createProject(projectView);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]);
|
||||
await this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]);
|
||||
} else {
|
||||
projectView.id = this.data.projectId;
|
||||
await this.updateProject(projectView);
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
@ -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"]);
|
||||
};
|
@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
@ -38,8 +39,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
||||
}),
|
||||
),
|
||||
catchError(async () => {
|
||||
this.logService.info("Error fetching project people access policies.");
|
||||
await this.router.navigate(["/sm", this.organizationId, "projects"]);
|
||||
return [];
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
@ -70,6 +72,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private accessPolicySelectorService: AccessPolicySelectorService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -4,6 +4,7 @@ import { combineLatest, combineLatestWith, filter, Observable, startWith, switch
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@ -42,6 +43,7 @@ export class ProjectSecretsComponent {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private organizationService: OrganizationService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@ -109,12 +111,13 @@ export class ProjectSecretsComponent {
|
||||
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
|
||||
}
|
||||
|
||||
copySecretValue(id: string) {
|
||||
SecretsListComponent.copySecretValue(
|
||||
async copySecretValue(id: string) {
|
||||
await SecretsListComponent.copySecretValue(
|
||||
id,
|
||||
this.platformUtilsService,
|
||||
this.i18nService,
|
||||
this.secretService,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
filter,
|
||||
Observable,
|
||||
startWith,
|
||||
@ -58,18 +56,6 @@ export class ProjectComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
|
||||
switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)),
|
||||
catchError(() => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/sm", this.organizationId, "projects"]).then(() => {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("notFound", this.i18nService.t("project")),
|
||||
);
|
||||
});
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
|
||||
const projectId$ = this.route.params.pipe(map((p) => p.projectId));
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { projectAccessGuard } from "./guards/project-access.guard";
|
||||
import { ProjectPeopleComponent } from "./project/project-people.component";
|
||||
import { ProjectSecretsComponent } from "./project/project-secrets.component";
|
||||
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
|
||||
@ -15,6 +16,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: ":projectId",
|
||||
component: ProjectComponent,
|
||||
canActivate: [projectAccessGuard],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
|
@ -199,7 +199,7 @@ export class SecretDialogComponent implements OnInit {
|
||||
return await this.projectService.create(this.data.organizationId, projectView);
|
||||
}
|
||||
|
||||
protected openDeleteSecretDialog() {
|
||||
protected async openDeleteSecretDialog() {
|
||||
const secretListView: SecretListView[] = this.getSecretListView();
|
||||
|
||||
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
|
||||
@ -212,9 +212,7 @@ export class SecretDialogComponent implements OnInit {
|
||||
);
|
||||
|
||||
// If the secret is deleted, chain close this dialog after the delete dialog
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
lastValueFrom(dialogRef.closed).then(
|
||||
await lastValueFrom(dialogRef.closed).then(
|
||||
(closeData) => closeData !== undefined && this.dialogRef.close(),
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { combineLatestWith, Observable, startWith, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@ -39,6 +40,7 @@ export class SecretsComponent implements OnInit {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private organizationService: OrganizationService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@ -97,12 +99,13 @@ export class SecretsComponent implements OnInit {
|
||||
SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService);
|
||||
}
|
||||
|
||||
copySecretValue(id: string) {
|
||||
SecretsListComponent.copySecretValue(
|
||||
async copySecretValue(id: string) {
|
||||
await SecretsListComponent.copySecretValue(
|
||||
id,
|
||||
this.platformUtilsService,
|
||||
this.i18nService,
|
||||
this.secretService,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -47,9 +47,7 @@ export class ServiceAccountDialogComponent {
|
||||
|
||||
async ngOnInit() {
|
||||
if (this.data.operation == OperationType.Edit) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.loadData();
|
||||
await this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
@ -1,6 +1,9 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ServiceAccountService } from "../service-account.service";
|
||||
|
||||
/**
|
||||
@ -8,6 +11,8 @@ import { ServiceAccountService } from "../service-account.service";
|
||||
*/
|
||||
export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
|
||||
const serviceAccountService = inject(ServiceAccountService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
const i18nService = inject(I18nService);
|
||||
|
||||
try {
|
||||
const serviceAccount = await serviceAccountService.getByServiceAccountId(
|
||||
@ -18,6 +23,12 @@ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedR
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
i18nService.t("notFound", i18nService.t("machineAccount")),
|
||||
);
|
||||
|
||||
return createUrlTreeFromSnapshot(route, [
|
||||
"/sm",
|
||||
route.params.organizationId,
|
||||
|
@ -1,15 +1,6 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
EMPTY,
|
||||
Subject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
filter,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { Subject, combineLatest, filter, startWith, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@ -42,18 +33,6 @@ export class ServiceAccountComponent implements OnInit, OnDestroy {
|
||||
params.organizationId,
|
||||
),
|
||||
),
|
||||
catchError(() => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/sm", this.organizationId, "machine-accounts"]).then(() => {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("notFound", this.i18nService.t("machineAccount")),
|
||||
);
|
||||
});
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
|
@ -93,8 +93,9 @@
|
||||
variant="secondary"
|
||||
class="tw-ml-1"
|
||||
[title]="project.name"
|
||||
maxWidthClass="tw-max-w-60"
|
||||
>
|
||||
{{ project.name | ellipsis: 32 }}
|
||||
{{ project.name }}
|
||||
</span>
|
||||
<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
|
||||
|
@ -3,6 +3,7 @@ import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
|
||||
@ -134,22 +135,24 @@ export class SecretsListComponent implements OnDestroy {
|
||||
/**
|
||||
* TODO: Refactor to smart component and remove
|
||||
*/
|
||||
static copySecretValue(
|
||||
static async copySecretValue(
|
||||
id: string,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
i18nService: I18nService,
|
||||
secretService: SecretService,
|
||||
logService: LogService,
|
||||
) {
|
||||
const value = secretService.getBySecretId(id).then((secret) => secret.value);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
SecretsListComponent.copyToClipboardAsync(value, platformUtilsService).then(() => {
|
||||
try {
|
||||
const value = await secretService.getBySecretId(id).then((secret) => secret.value);
|
||||
platformUtilsService.copyToClipboard(value);
|
||||
platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
i18nService.t("valueCopied", i18nService.t("value")),
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
logService.info("Error fetching secret value.");
|
||||
}
|
||||
}
|
||||
|
||||
static copySecretUuid(
|
||||
|
@ -13,9 +13,11 @@
|
||||
</h1>
|
||||
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
|
@ -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 = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs";
|
||||
import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs";
|
||||
|
||||
/** Test class to enable async awaiting of observable emissions */
|
||||
export class ObservableTracker<T> {
|
||||
private subscription: Subscription;
|
||||
private emissionReceived = new Subject<T>();
|
||||
emissions: T[] = [];
|
||||
constructor(private observable: Observable<T>) {
|
||||
constructor(observable: Observable<T>) {
|
||||
this.emissions = this.trackEmissions(observable);
|
||||
}
|
||||
|
||||
@ -21,7 +22,7 @@ export class ObservableTracker<T> {
|
||||
*/
|
||||
async expectEmission(msTimeout = 50): Promise<T> {
|
||||
return await firstValueFrom(
|
||||
this.observable.pipe(
|
||||
this.emissionReceived.pipe(
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")),
|
||||
@ -34,40 +35,38 @@ export class ObservableTracker<T> {
|
||||
* @param count The number of emissions to wait for
|
||||
*/
|
||||
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);
|
||||
}
|
||||
return this.emissions;
|
||||
}
|
||||
|
||||
private trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
private trackEmissions(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
this.subscription = observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
if (value == null) {
|
||||
this.emissionReceived.next(null);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
this.emissionReceived.next(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
this.emissionReceived.next(value as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
this.emissionReceived.next(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.emissionReceived.subscribe((value) => {
|
||||
emissions.push(value);
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,23 @@
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { FakeGlobalState, FakeGlobalStateProvider, ObservableTracker } from "../../../spec";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service";
|
||||
|
||||
describe("AppIdService", () => {
|
||||
const globalStateProvider = new FakeGlobalStateProvider();
|
||||
const appIdState = globalStateProvider.getFake(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY);
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
let appIdState: FakeGlobalState<string>;
|
||||
let anonymousAppIdState: FakeGlobalState<string>;
|
||||
let sut: AppIdService;
|
||||
|
||||
beforeEach(() => {
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
appIdState = globalStateProvider.getFake(APP_ID_KEY);
|
||||
anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY);
|
||||
sut = new AppIdService(globalStateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getAppId", () => {
|
||||
@ -26,19 +29,18 @@ describe("AppIdService", () => {
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
it("creates a new appId only once", async () => {
|
||||
appIdState.stateSubject.next(null);
|
||||
|
||||
await sut.getAppId();
|
||||
const appIds: string[] = [];
|
||||
const promises = [async () => appIds.push(await sut.getAppId())];
|
||||
promises.push(async () => appIds.push(await sut.getAppId()));
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
expect(appIds[0]).toBe(appIds[1]);
|
||||
});
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
it.each([null, undefined])("returns a new appId when %s", async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
@ -46,16 +48,23 @@ describe("AppIdService", () => {
|
||||
expect(appId).toMatch(Utils.guidRegex);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"stores the new guid when it an existing one is not found",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
it.each([null, undefined])("stores the new guid when %s", async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
expect(appIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
});
|
||||
|
||||
it("emits only once when creating a new appId", async () => {
|
||||
appIdState.stateSubject.next(null);
|
||||
|
||||
const tracker = new ObservableTracker(sut.appId$);
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(tracker.emissions).toEqual([appId]);
|
||||
await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnonymousAppId", () => {
|
||||
@ -67,17 +76,16 @@ describe("AppIdService", () => {
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
it("creates a new anonymousAppId only once", async () => {
|
||||
anonymousAppIdState.stateSubject.next(null);
|
||||
|
||||
await sut.getAnonymousAppId();
|
||||
const appIds: string[] = [];
|
||||
const promises = [async () => appIds.push(await sut.getAnonymousAppId())];
|
||||
promises.push(async () => appIds.push(await sut.getAnonymousAppId()));
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
expect(appIds[0]).toBe(appIds[1]);
|
||||
});
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
@ -97,5 +105,15 @@ describe("AppIdService", () => {
|
||||
expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
|
||||
it("emits only once when creating a new anonymousAppId", async () => {
|
||||
anonymousAppIdState.stateSubject.next(null);
|
||||
|
||||
const tracker = new ObservableTracker(sut.anonymousAppId$);
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(tracker.emissions).toEqual([appId]);
|
||||
await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Observable, filter, firstValueFrom, tap } from "rxjs";
|
||||
import { Observable, concatMap, distinctUntilChanged, firstValueFrom, share } from "rxjs";
|
||||
|
||||
import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
@ -19,20 +19,28 @@ export class AppIdService implements AppIdServiceAbstraction {
|
||||
const appIdState = globalStateProvider.get(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY);
|
||||
this.appId$ = appIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
concatMap(async (appId) => {
|
||||
if (!appId) {
|
||||
await appIdState.update(() => Utils.newGuid());
|
||||
return await appIdState.update(() => Utils.newGuid(), {
|
||||
shouldUpdate: (v) => v == null,
|
||||
});
|
||||
}
|
||||
return appId;
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
distinctUntilChanged(),
|
||||
share(),
|
||||
);
|
||||
this.anonymousAppId$ = anonymousAppIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
concatMap(async (appId) => {
|
||||
if (!appId) {
|
||||
await anonymousAppIdState.update(() => Utils.newGuid());
|
||||
return await anonymousAppIdState.update(() => Utils.newGuid(), {
|
||||
shouldUpdate: (v) => v == null,
|
||||
});
|
||||
}
|
||||
return appId;
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
distinctUntilChanged(),
|
||||
share(),
|
||||
);
|
||||
}
|
||||
|
||||
|
39
libs/common/src/tools/generator/abstractions/randomizer.ts
Normal file
39
libs/common/src/tools/generator/abstractions/randomizer.ts
Normal 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>;
|
||||
}
|
@ -40,11 +40,11 @@ import {
|
||||
import {
|
||||
GeneratedPasswordHistory,
|
||||
PasswordGenerationOptions,
|
||||
PasswordGenerationService,
|
||||
PasswordGeneratorOptions,
|
||||
PasswordGeneratorPolicy,
|
||||
PasswordGeneratorStrategy,
|
||||
} from "./password";
|
||||
import { CryptoServiceRandomizer } from "./random";
|
||||
|
||||
type MappedOptions = {
|
||||
generator: GeneratorNavigation;
|
||||
@ -60,17 +60,15 @@ export function legacyPasswordGenerationServiceFactory(
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
): PasswordGenerationServiceAbstraction {
|
||||
// FIXME: Once the password generation service is replaced with this service
|
||||
// in the clients, factor out the deprecated service in its entirety.
|
||||
const deprecatedService = new PasswordGenerationService(cryptoService, null, null);
|
||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||
|
||||
const passwords = new DefaultGeneratorService(
|
||||
new PasswordGeneratorStrategy(deprecatedService, stateProvider),
|
||||
new PasswordGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const passphrases = new DefaultGeneratorService(
|
||||
new PassphraseGeneratorStrategy(deprecatedService, stateProvider),
|
||||
new PassphraseGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
|
@ -14,6 +14,7 @@ import { DefaultGeneratorService } from "./default-generator.service";
|
||||
import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service";
|
||||
import { GeneratorNavigation } from "./navigation/generator-navigation";
|
||||
import { NoPolicy } from "./no-policy";
|
||||
import { CryptoServiceRandomizer } from "./random";
|
||||
import {
|
||||
CatchallGeneratorStrategy,
|
||||
SubaddressGeneratorStrategy,
|
||||
@ -37,7 +38,6 @@ import {
|
||||
} from "./username/options/forwarder-options";
|
||||
import { SubaddressGenerationOptions } from "./username/subaddress-generator-options";
|
||||
import { UsernameGeneratorOptions } from "./username/username-generation-options";
|
||||
import { UsernameGenerationService } from "./username/username-generation.service";
|
||||
|
||||
type MappedOptions = {
|
||||
generator: GeneratorNavigation;
|
||||
@ -65,22 +65,20 @@ export function legacyUsernameGenerationServiceFactory(
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
): UsernameGenerationServiceAbstraction {
|
||||
// FIXME: Once the username generation service is replaced with this service
|
||||
// in the clients, factor out the deprecated service in its entirety.
|
||||
const deprecatedService = new UsernameGenerationService(cryptoService, null, null);
|
||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||
|
||||
const effUsername = new DefaultGeneratorService(
|
||||
new EffUsernameGeneratorStrategy(deprecatedService, stateProvider),
|
||||
new EffUsernameGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const subaddress = new DefaultGeneratorService(
|
||||
new SubaddressGeneratorStrategy(deprecatedService, stateProvider),
|
||||
new SubaddressGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const catchall = new DefaultGeneratorService(
|
||||
new CatchallGeneratorStrategy(deprecatedService, stateProvider),
|
||||
new CatchallGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
|
@ -11,7 +11,7 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { PASSPHRASE_SETTINGS } from "../key-definitions";
|
||||
|
||||
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
|
||||
@ -65,8 +65,8 @@ describe("Password generation strategy", () => {
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PassphraseGeneratorStrategy(legacy, provider);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PassphraseGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
@ -86,36 +86,14 @@ describe("Password generation strategy", () => {
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PassphraseGeneratorStrategy(legacy, null);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PassphraseGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<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" });
|
||||
});
|
||||
it.todo("should generate a password using the given options");
|
||||
});
|
||||
});
|
||||
|
@ -1,25 +1,20 @@
|
||||
import { BehaviorSubject, map, pipe } from "rxjs";
|
||||
|
||||
import { GeneratorStrategy } from "..";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { EFFLongWordList } from "../../../platform/misc/wordlist";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { PASSPHRASE_SETTINGS } from "../key-definitions";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "../rx-operators";
|
||||
import { Policies } from "../policies";
|
||||
import { mapPolicyToEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import {
|
||||
PassphraseGenerationOptions,
|
||||
DefaultPassphraseGenerationOptions,
|
||||
} from "./passphrase-generation-options";
|
||||
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
||||
import {
|
||||
DisabledPassphraseGeneratorPolicy,
|
||||
PassphraseGeneratorPolicy,
|
||||
leastPrivilege,
|
||||
} from "./passphrase-generator-policy";
|
||||
import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
|
||||
|
||||
/** {@link GeneratorStrategy} */
|
||||
/** Generates passphrases composed of random words */
|
||||
export class PassphraseGeneratorStrategy
|
||||
implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy>
|
||||
{
|
||||
@ -28,36 +23,48 @@ export class PassphraseGeneratorStrategy
|
||||
* @param stateProvider provides durable state
|
||||
*/
|
||||
constructor(
|
||||
private legacy: PasswordGenerationServiceAbstraction,
|
||||
private randomizer: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState(id: UserId) {
|
||||
return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS);
|
||||
}
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$(_: UserId) {
|
||||
return new BehaviorSubject({ ...DefaultPassphraseGenerationOptions }).asObservable();
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.toEvaluator} */
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return pipe(
|
||||
reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy),
|
||||
distinctIfShallowMatch(),
|
||||
map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)),
|
||||
);
|
||||
return mapPolicyToEvaluator(Policies.Passphrase);
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: PassphraseGenerationOptions): Promise<string> {
|
||||
return this.legacy.generatePassphrase({ ...options, type: "passphrase" });
|
||||
// algorithm
|
||||
async generate(options: PassphraseGenerationOptions): Promise<string> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -7,5 +7,4 @@ export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||
// legacy interfaces
|
||||
export { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
|
||||
export { PasswordGenerationService } from "./password-generation.service";
|
||||
export { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
|
@ -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]];
|
||||
}
|
||||
}
|
||||
}
|
@ -12,13 +12,13 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { PASSWORD_SETTINGS } from "../key-definitions";
|
||||
|
||||
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
|
||||
import {
|
||||
DefaultPasswordGenerationOptions,
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PasswordGeneratorOptionsEvaluator,
|
||||
PasswordGeneratorStrategy,
|
||||
} from ".";
|
||||
@ -74,8 +74,8 @@ describe("Password generation strategy", () => {
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy, provider);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PasswordGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
@ -95,40 +95,14 @@ describe("Password generation strategy", () => {
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy, null);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PasswordGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<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" });
|
||||
});
|
||||
it.todo("should generate a password using the given options");
|
||||
});
|
||||
});
|
||||
|
@ -1,25 +1,19 @@
|
||||
import { BehaviorSubject, map, pipe } from "rxjs";
|
||||
|
||||
import { GeneratorStrategy } from "..";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { PASSWORD_SETTINGS } from "../key-definitions";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "../rx-operators";
|
||||
import { Policies } from "../policies";
|
||||
import { mapPolicyToEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import {
|
||||
DefaultPasswordGenerationOptions,
|
||||
PasswordGenerationOptions,
|
||||
} from "./password-generation-options";
|
||||
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
import {
|
||||
DisabledPasswordGeneratorPolicy,
|
||||
PasswordGeneratorPolicy,
|
||||
leastPrivilege,
|
||||
} from "./password-generator-policy";
|
||||
import { PasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
|
||||
/** {@link GeneratorStrategy} */
|
||||
/** Generates passwords composed of random characters */
|
||||
export class PasswordGeneratorStrategy
|
||||
implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy>
|
||||
{
|
||||
@ -27,36 +21,108 @@ export class PasswordGeneratorStrategy
|
||||
* @param legacy generates the password
|
||||
*/
|
||||
constructor(
|
||||
private legacy: PasswordGenerationServiceAbstraction,
|
||||
private randomizer: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState(id: UserId) {
|
||||
return this.stateProvider.getUser(id, PASSWORD_SETTINGS);
|
||||
}
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$(_: UserId) {
|
||||
return new BehaviorSubject({ ...DefaultPasswordGenerationOptions }).asObservable();
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.toEvaluator} */
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return pipe(
|
||||
reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy),
|
||||
distinctIfShallowMatch(),
|
||||
map((policy) => new PasswordGeneratorOptionsEvaluator(policy)),
|
||||
);
|
||||
return mapPolicyToEvaluator(Policies.Password);
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: PasswordGenerationOptions): Promise<string> {
|
||||
return this.legacy.generatePassword({ ...options, type: "password" });
|
||||
// algorithm
|
||||
async generate(options: PasswordGenerationOptions): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
48
libs/common/src/tools/generator/policies.ts
Normal file
48
libs/common/src/tools/generator/policies.ts
Normal 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,
|
||||
});
|
62
libs/common/src/tools/generator/random.ts
Normal file
62
libs/common/src/tools/generator/random.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
import { distinctUntilChanged, map, OperatorFunction } from "rxjs";
|
||||
import { distinctUntilChanged, map, OperatorFunction, pipe } from "rxjs";
|
||||
|
||||
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
||||
import { PolicyConfiguration } from "./policies";
|
||||
|
||||
/**
|
||||
* An observable operator that reduces an emitted collection to a single object,
|
||||
@ -36,3 +39,23 @@ export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
|
||||
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>()));
|
||||
};
|
||||
}
|
||||
|
@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { CATCHALL_SETTINGS } from "../key-definitions";
|
||||
|
||||
import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options";
|
||||
import { DefaultCatchallOptions } from "./catchall-generator-options";
|
||||
|
||||
import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
|
||||
import { CatchallGeneratorStrategy } from ".";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
@ -40,8 +41,8 @@ describe("Email subaddress list generation strategy", () => {
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new CatchallGeneratorStrategy(legacy, provider);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new CatchallGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
@ -61,26 +62,14 @@ describe("Email subaddress list generation strategy", () => {
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new CatchallGeneratorStrategy(legacy, null);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new CatchallGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<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);
|
||||
});
|
||||
it.todo("generate catchall email addresses");
|
||||
});
|
||||
});
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { BehaviorSubject, map, pipe } from "rxjs";
|
||||
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { CATCHALL_SETTINGS } from "../key-definitions";
|
||||
import { NoPolicy } from "../no-policy";
|
||||
import { newDefaultEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options";
|
||||
|
||||
@ -19,34 +17,34 @@ export class CatchallGeneratorStrategy
|
||||
* @param usernameService generates a catchall address for a domain
|
||||
*/
|
||||
constructor(
|
||||
private usernameService: UsernameGenerationServiceAbstraction,
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions,
|
||||
) {}
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState(id: UserId) {
|
||||
return this.stateProvider.getUser(id, CATCHALL_SETTINGS);
|
||||
}
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
/** {@link GeneratorStrategy.defaults$} */
|
||||
defaults$(userId: UserId) {
|
||||
return new BehaviorSubject({ ...DefaultCatchallOptions }).asObservable();
|
||||
}
|
||||
// algorithm
|
||||
async generate(options: CatchallGenerationOptions) {
|
||||
const o = Object.assign({}, DefaultCatchallOptions, options);
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
// Uses password generator since there aren't policies
|
||||
// specific to usernames.
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
if (o.catchallDomain == null || o.catchallDomain === "") {
|
||||
return null;
|
||||
}
|
||||
if (o.catchallType == null) {
|
||||
o.catchallType = "random";
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.toEvaluator} */
|
||||
toEvaluator() {
|
||||
return pipe(map((_) => new DefaultPolicyEvaluator<CatchallGenerationOptions>()));
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: CatchallGenerationOptions) {
|
||||
return this.usernameService.generateCatchall(options);
|
||||
let startString = "";
|
||||
if (o.catchallType === "random") {
|
||||
startString = await this.random.chars(8);
|
||||
} else if (o.catchallType === "website-name") {
|
||||
startString = o.website;
|
||||
}
|
||||
return startString + "@" + o.catchallDomain;
|
||||
}
|
||||
}
|
||||
|
@ -7,12 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
|
||||
|
||||
import { DefaultEffUsernameOptions } from "./eff-username-generator-options";
|
||||
|
||||
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
|
||||
import { EffUsernameGeneratorStrategy } from ".";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
@ -40,8 +41,8 @@ describe("EFF long word list generation strategy", () => {
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(legacy, provider);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
@ -61,26 +62,14 @@ describe("EFF long word list generation strategy", () => {
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<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);
|
||||
});
|
||||
it.todo("generate username tests");
|
||||
});
|
||||
});
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { BehaviorSubject, map, pipe } from "rxjs";
|
||||
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
|
||||
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
|
||||
import { NoPolicy } from "../no-policy";
|
||||
import { newDefaultEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import {
|
||||
DefaultEffUsernameOptions,
|
||||
@ -22,34 +22,23 @@ export class EffUsernameGeneratorStrategy
|
||||
* @param usernameService generates a username from EFF word list
|
||||
*/
|
||||
constructor(
|
||||
private usernameService: UsernameGenerationServiceAbstraction,
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions,
|
||||
) {}
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState(id: UserId) {
|
||||
return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS);
|
||||
}
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
/** {@link GeneratorStrategy.defaults$} */
|
||||
defaults$(userId: UserId) {
|
||||
return new BehaviorSubject({ ...DefaultEffUsernameOptions }).asObservable();
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
// Uses password generator since there aren't policies
|
||||
// specific to usernames.
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.toEvaluator} */
|
||||
toEvaluator() {
|
||||
return pipe(map((_) => new DefaultPolicyEvaluator<EffUsernameGenerationOptions>()));
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: EffUsernameGenerationOptions) {
|
||||
return this.usernameService.generateWord(options);
|
||||
// algorithm
|
||||
async generate(options: EffUsernameGenerationOptions) {
|
||||
const word = await this.random.pickWord(EFFLongWordList, {
|
||||
titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize,
|
||||
number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber,
|
||||
});
|
||||
return word;
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, { website: null, token: "" });
|
||||
}
|
||||
|
||||
get key() {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Observable, map, pipe } from "rxjs";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
@ -13,8 +13,9 @@ import { SecretKeyDefinition } from "../../state/secret-key-definition";
|
||||
import { SecretState } from "../../state/secret-state";
|
||||
import { UserKeyEncryptor } from "../../state/user-key-encryptor";
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { NoPolicy } from "../no-policy";
|
||||
import { newDefaultEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedByUserId } from "../util";
|
||||
|
||||
import { ApiOptions } from "./options/forwarder-options";
|
||||
|
||||
@ -33,29 +34,25 @@ export abstract class ForwarderGeneratorStrategy<
|
||||
private readonly encryptService: EncryptService,
|
||||
private readonly keyService: CryptoService,
|
||||
private stateProvider: StateProvider,
|
||||
private readonly defaultOptions: Options,
|
||||
) {
|
||||
super();
|
||||
// Uses password generator since there aren't policies
|
||||
// specific to usernames.
|
||||
this.policy = PolicyType.PasswordGenerator;
|
||||
}
|
||||
|
||||
private durableStates = new Map<UserId, SingleUserState<Options>>();
|
||||
/** configures forwarder secret storage */
|
||||
protected abstract readonly key: UserKeyDefinition<Options>;
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState = (userId: UserId) => {
|
||||
let state = this.durableStates.get(userId);
|
||||
/** configures forwarder import buffer */
|
||||
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
|
||||
|
||||
if (!state) {
|
||||
state = this.createState(userId);
|
||||
// configuration
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<Options>();
|
||||
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
|
||||
|
||||
this.durableStates.set(userId, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
private createState(userId: UserId): SingleUserState<Options> {
|
||||
// per-user encrypted state
|
||||
private getUserSecrets(userId: UserId): SingleUserState<Options> {
|
||||
// construct the encryptor
|
||||
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
||||
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||
@ -92,18 +89,4 @@ export abstract class ForwarderGeneratorStrategy<
|
||||
|
||||
return rolloverState;
|
||||
}
|
||||
|
||||
/** Gets the default options. */
|
||||
abstract defaults$: (userId: UserId) => Observable<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>()));
|
||||
};
|
||||
}
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -36,25 +33,14 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy<
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return ADDY_IO_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = ADDY_IO_FORWARDER;
|
||||
readonly rolloverKey = ADDY_IO_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return ADDY_IO_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultAddyIoOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -32,25 +29,14 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions>
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return DUCK_DUCK_GO_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = DUCK_DUCK_GO_FORWARDER;
|
||||
readonly rolloverKey = DUCK_DUCK_GO_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return DUCK_DUCK_GO_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultDuckDuckGoOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: ApiOptions): Promise<string> => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -34,25 +31,14 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & E
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultFastmailOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return FASTMAIL_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = FASTMAIL_FORWARDER;
|
||||
readonly rolloverKey = FASTMAIL_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultFastmailOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return FASTMAIL_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: ApiOptions & EmailPrefixOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
@ -76,7 +62,7 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & E
|
||||
"new-masked-email": {
|
||||
state: "enabled",
|
||||
description: "",
|
||||
forDomain: options.website,
|
||||
forDomain: options.website ?? "",
|
||||
emailPrefix: options.prefix,
|
||||
},
|
||||
},
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -32,25 +29,14 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return FIREFOX_RELAY_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = FIREFOX_RELAY_FORWARDER;
|
||||
readonly rolloverKey = FIREFOX_RELAY_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return FIREFOX_RELAY_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultFirefoxRelayOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: ApiOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
|
||||
|
@ -1,12 +1,9 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -36,25 +33,14 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return FORWARD_EMAIL_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = FORWARD_EMAIL_FORWARDER;
|
||||
readonly rolloverKey = FORWARD_EMAIL_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultForwardEmailOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return FORWARD_EMAIL_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: ApiOptions & EmailDomainOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "../../../../platform/state";
|
||||
import { UserId } from "../../../../types/guid";
|
||||
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { Forwarders } from "../options/constants";
|
||||
@ -33,25 +30,14 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedA
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider);
|
||||
super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions);
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.key} */
|
||||
get key() {
|
||||
return SIMPLE_LOGIN_FORWARDER;
|
||||
}
|
||||
// configuration
|
||||
readonly key = SIMPLE_LOGIN_FORWARDER;
|
||||
readonly rolloverKey = SIMPLE_LOGIN_BUFFER;
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.rolloverKey} */
|
||||
get rolloverKey() {
|
||||
return SIMPLE_LOGIN_BUFFER;
|
||||
}
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.defaults$} */
|
||||
defaults$ = (userId: UserId) => {
|
||||
return new BehaviorSubject({ ...DefaultSimpleLoginOptions });
|
||||
};
|
||||
|
||||
/** {@link ForwarderGeneratorStrategy.generate} */
|
||||
// request
|
||||
generate = async (options: SelfHostedApiOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
|
||||
|
@ -3,4 +3,3 @@ export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
||||
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
|
||||
export { UsernameGeneratorOptions } from "./username-generation-options";
|
||||
export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";
|
||||
export { UsernameGenerationService } from "./username-generation.service";
|
||||
|
@ -7,15 +7,13 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { SUBADDRESS_SETTINGS } from "../key-definitions";
|
||||
|
||||
import {
|
||||
DefaultSubaddressOptions,
|
||||
SubaddressGenerationOptions,
|
||||
} from "./subaddress-generator-options";
|
||||
import { DefaultSubaddressOptions } from "./subaddress-generator-options";
|
||||
|
||||
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
|
||||
import { SubaddressGeneratorStrategy } from ".";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
@ -43,8 +41,8 @@ describe("Email subaddress list generation strategy", () => {
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new SubaddressGeneratorStrategy(legacy, provider);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new SubaddressGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
@ -64,26 +62,14 @@ describe("Email subaddress list generation strategy", () => {
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<UsernameGenerationServiceAbstraction>();
|
||||
const strategy = new SubaddressGeneratorStrategy(legacy, null);
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new SubaddressGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<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);
|
||||
});
|
||||
it.todo("generate email subaddress tests");
|
||||
});
|
||||
});
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { BehaviorSubject, map, pipe } from "rxjs";
|
||||
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";
|
||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||
import { Randomizer } from "../abstractions/randomizer";
|
||||
import { SUBADDRESS_SETTINGS } from "../key-definitions";
|
||||
import { NoPolicy } from "../no-policy";
|
||||
import { newDefaultEvaluator } from "../rx-operators";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import {
|
||||
DefaultSubaddressOptions,
|
||||
@ -26,34 +24,42 @@ export class SubaddressGeneratorStrategy
|
||||
* @param usernameService generates an email subaddress from an email address
|
||||
*/
|
||||
constructor(
|
||||
private usernameService: UsernameGenerationServiceAbstraction,
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions,
|
||||
) {}
|
||||
|
||||
/** {@link GeneratorStrategy.durableState} */
|
||||
durableState(id: UserId) {
|
||||
return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS);
|
||||
}
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
/** {@link GeneratorStrategy.defaults$} */
|
||||
defaults$(userId: UserId) {
|
||||
return new BehaviorSubject({ ...DefaultSubaddressOptions }).asObservable();
|
||||
}
|
||||
// algorithm
|
||||
async generate(options: SubaddressGenerationOptions) {
|
||||
const o = Object.assign({}, DefaultSubaddressOptions, options);
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
// Uses password generator since there aren't policies
|
||||
// specific to usernames.
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
const subaddressEmail = o.subaddressEmail;
|
||||
if (subaddressEmail == null || subaddressEmail.length < 3) {
|
||||
return o.subaddressEmail;
|
||||
}
|
||||
const atIndex = subaddressEmail.indexOf("@");
|
||||
if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) {
|
||||
return subaddressEmail;
|
||||
}
|
||||
if (o.subaddressType == null) {
|
||||
o.subaddressType = "random";
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.toEvaluator} */
|
||||
toEvaluator() {
|
||||
return pipe(map((_) => new DefaultPolicyEvaluator<SubaddressGenerationOptions>()));
|
||||
}
|
||||
const emailBeginning = subaddressEmail.substr(0, atIndex);
|
||||
const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length);
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: SubaddressGenerationOptions) {
|
||||
return this.usernameService.generateSubaddress(options);
|
||||
let subaddressString = "";
|
||||
if (o.subaddressType === "random") {
|
||||
subaddressString = await this.random.chars(8);
|
||||
} else if (o.subaddressType === "website-name") {
|
||||
subaddressString = o.website;
|
||||
}
|
||||
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
41
libs/common/src/tools/generator/util.ts
Normal file
41
libs/common/src/tools/generator/util.ts
Normal 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);
|
||||
}
|
6
libs/common/src/tools/generator/word-options.ts
Normal file
6
libs/common/src/tools/generator/word-options.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type WordOptions = {
|
||||
/** set the first letter uppercase */
|
||||
titleCase?: boolean;
|
||||
/** append a number */
|
||||
number?: boolean;
|
||||
};
|
@ -6,7 +6,7 @@ import {
|
||||
awaitAsync,
|
||||
trackEmissions,
|
||||
} from "../../../spec";
|
||||
import { GENERATOR_DISK, KeyDefinition } from "../../platform/state";
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { BufferedKeyDefinition } from "./buffered-key-definition";
|
||||
@ -16,8 +16,9 @@ const SomeUser = "SomeUser" as UserId;
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
type SomeType = { foo: boolean; bar: boolean };
|
||||
|
||||
const SOME_KEY = new KeyDefinition<SomeType>(GENERATOR_DISK, "fooBar", {
|
||||
const SOME_KEY = new UserKeyDefinition<SomeType>(GENERATOR_DISK, "fooBar", {
|
||||
deserializer: (jsonValue) => jsonValue as SomeType,
|
||||
clearOn: [],
|
||||
});
|
||||
const BUFFER_KEY = new BufferedKeyDefinition<SomeType>(GENERATOR_DISK, "fooBar_buffer", {
|
||||
deserializer: (jsonValue) => jsonValue as SomeType,
|
||||
|
@ -13,6 +13,7 @@ import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
|
||||
export abstract class CipherService {
|
||||
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
||||
ciphers$: Observable<Record<CipherId, CipherData>>;
|
||||
/**
|
||||
* An observable monitoring the add/edit cipher info saved to memory.
|
||||
*/
|
||||
|
@ -142,6 +142,17 @@ export class IdentityView extends ItemView {
|
||||
return addressPart2;
|
||||
}
|
||||
|
||||
get fullAddressForCopy(): string {
|
||||
let address = this.fullAddress;
|
||||
if (this.city != null || this.state != null || this.postalCode != null) {
|
||||
address += "\n" + this.fullAddressPart2;
|
||||
}
|
||||
if (this.country != null) {
|
||||
address += "\n" + this.country;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<IdentityView>>): IdentityView {
|
||||
return Object.assign(new IdentityView(), obj);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, map, Observable, share, skipWhile, switchMap } from "rxjs";
|
||||
import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
@ -125,13 +125,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
switchMap(() => this.encryptedCiphersState.state$),
|
||||
map((ciphers) => ciphers ?? {}),
|
||||
);
|
||||
this.cipherViews$ = this.decryptedCiphersState.state$.pipe(
|
||||
map((views) => views ?? {}),
|
||||
|
||||
share({
|
||||
resetOnRefCountZero: true,
|
||||
}),
|
||||
);
|
||||
this.cipherViews$ = this.decryptedCiphersState.state$.pipe(map((views) => views ?? {}));
|
||||
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
|
||||
}
|
||||
|
||||
|
@ -49,7 +49,7 @@ export class BadgeDirective implements FocusableElement {
|
||||
]
|
||||
.concat(styles[this.variant])
|
||||
.concat(this.hasHoverEffects ? hoverStyles[this.variant] : [])
|
||||
.concat(this.truncate ? ["tw-truncate", "tw-max-w-40"] : []);
|
||||
.concat(this.truncate ? ["tw-truncate", this.maxWidthClass] : []);
|
||||
}
|
||||
@HostBinding("attr.title") get title() {
|
||||
return this.truncate ? this.el.nativeElement.textContent.trim() : null;
|
||||
@ -65,6 +65,8 @@ export class BadgeDirective implements FocusableElement {
|
||||
*/
|
||||
@Input() truncate = true;
|
||||
|
||||
@Input() maxWidthClass: `tw-max-w-${string}` = "tw-max-w-40";
|
||||
|
||||
getFocusTarget() {
|
||||
return this.el.nativeElement;
|
||||
}
|
||||
|
78
libs/vault/src/components/copy-cipher-field.directive.ts
Normal file
78
libs/vault/src/components/copy-cipher-field.directive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1,3 @@
|
||||
export { PasswordRepromptService } from "./services/password-reprompt.service";
|
||||
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
|
||||
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
||||
|
170
libs/vault/src/services/copy-cipher-field.service.spec.ts
Normal file
170
libs/vault/src/services/copy-cipher-field.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
142
libs/vault/src/services/copy-cipher-field.service.ts
Normal file
142
libs/vault/src/services/copy-cipher-field.service.ts
Normal 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$)))
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user