Merge branch 'main' into vault/pm-9666/implement-edit-item-view-individual-vault
This commit is contained in:
commit
788348496c
|
@ -100,12 +100,19 @@ apps/desktop/src/services/native-message-handler.service.ts @bitwarden/team-auto
|
|||
## Component Library ##
|
||||
.storybook @bitwarden/team-design-system
|
||||
libs/components @bitwarden/team-design-system
|
||||
apps/browser/src/platform/popup/layout @bitwarden/team-design-system
|
||||
apps/web/src/app/layouts @bitwarden/team-design-system
|
||||
apps/browser/src/platform/popup/layout @bitwarden/team-design-system
|
||||
apps/web/src/app/layouts @bitwarden/team-design-system
|
||||
|
||||
## Desktop native module ##
|
||||
apps/desktop/desktop_native @bitwarden/team-platform-dev
|
||||
|
||||
## Key management team files ##
|
||||
apps/desktop/src/key-management @bitwarden/team-key-management-dev
|
||||
apps/web/src/key-management @bitwarden/team-key-management-dev
|
||||
apps/browser/src/key-management @bitwarden/team-key-management-dev
|
||||
apps/cli/src/key-management @bitwarden/team-key-management-dev
|
||||
libs/common/src/key-management @bitwarden/team-key-management-dev
|
||||
|
||||
## DevOps team files ##
|
||||
/.github/workflows @bitwarden/dept-devops
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2024.9.0",
|
||||
"version": "2024.9.1",
|
||||
"scripts": {
|
||||
"build": "cross-env MANIFEST_VERSION=3 webpack",
|
||||
"build:mv2": "webpack",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.9.0",
|
||||
"version": "2024.9.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.9.0",
|
||||
"version": "2024.9.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
|
||||
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
|
||||
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
|
||||
import {
|
||||
AnonLayoutWrapperComponent,
|
||||
|
@ -98,7 +99,6 @@ import { TrashComponent } from "../vault/popup/settings/trash.component";
|
|||
import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component";
|
||||
import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component";
|
||||
|
||||
import { extensionRefreshRedirect } from "./extension-refresh-route-utils";
|
||||
import { debounceNavigationGuard } from "./services/debounce-navigation.service";
|
||||
import { TabsV2Component } from "./tabs-v2.component";
|
||||
import { TabsComponent } from "./tabs.component";
|
||||
|
|
|
@ -1 +1 @@
|
|||
<bit-password-settings />
|
||||
<bit-password-generator />
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import { Component } from "@angular/core";
|
||||
|
||||
import {
|
||||
PassphraseSettingsComponent,
|
||||
PasswordSettingsComponent,
|
||||
} from "@bitwarden/generator-components";
|
||||
import { PasswordGeneratorComponent } from "@bitwarden/generator-components";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "credential-generator",
|
||||
templateUrl: "credential-generator.component.html",
|
||||
imports: [PassphraseSettingsComponent, PasswordSettingsComponent],
|
||||
imports: [PasswordGeneratorComponent],
|
||||
})
|
||||
export class CredentialGeneratorComponent {}
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
"chalk": "4.1.2",
|
||||
"commander": "11.1.0",
|
||||
"form-data": "4.0.0",
|
||||
"https-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.5",
|
||||
"inquirer": "8.2.6",
|
||||
"jsdom": "24.1.3",
|
||||
"jszip": "3.10.1",
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"**/node_modules/argon2/package.json",
|
||||
"**/node_modules/argon2/build/Release/argon2.node"
|
||||
],
|
||||
"electronVersion": "32.0.1",
|
||||
"electronVersion": "32.0.2",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
class="tw-px-2 tw-py-4"
|
||||
[ngClass]="{
|
||||
'tw-py-1': !(selectableProduct === selectedPlan),
|
||||
'tw-py-0': selectableProduct === selectedPlan,
|
||||
'tw-py-0': selectableProduct === selectedPlan
|
||||
}"
|
||||
>
|
||||
<h3 class="tw-text-lg tw-font-bold">
|
||||
|
@ -308,9 +308,14 @@
|
|||
<a></a>
|
||||
</p>
|
||||
<app-payment
|
||||
*ngIf="upgradeRequiresPaymentMethod || showPayment"
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI"
|
||||
[hideCredit]="true"
|
||||
></app-payment>
|
||||
<app-payment-v2
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-payment-v2>
|
||||
<app-tax-info
|
||||
*ngIf="showPayment || upgradeRequiresPaymentMethod"
|
||||
(onCountryChanged)="changedCountry()"
|
||||
|
@ -389,17 +394,21 @@
|
|||
bitTypography="body2"
|
||||
*ngIf="
|
||||
selectedPlan.PasswordManager.hasAdditionalStorageOption &&
|
||||
!organization.useSecretsManager
|
||||
!organization.useSecretsManager &&
|
||||
organization.maxStorageGb > 0
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ 0 }}
|
||||
{{ organization.maxStorageGb }}
|
||||
{{ "additionalStorageGbMessage" | i18n }}
|
||||
×
|
||||
{{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
|
||||
{{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }}
|
||||
/{{ "year" | i18n }}
|
||||
</span>
|
||||
<span>{{ 0 | currency: "$" }}</span>
|
||||
<span>{{
|
||||
organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb
|
||||
| currency: "$"
|
||||
}}</span>
|
||||
</p>
|
||||
<!-- secrets manager summary for annual -->
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
|
||||
|
@ -505,17 +514,21 @@
|
|||
bitTypography="body2"
|
||||
*ngIf="
|
||||
selectedPlan.PasswordManager.hasAdditionalStorageOption &&
|
||||
!organization.useSecretsManager
|
||||
!organization.useSecretsManager &&
|
||||
organization.maxStorageGb > 0
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ 0 }}
|
||||
{{ organization.maxStorageGb }}
|
||||
{{ "additionalStorageGbMessage" | i18n }}
|
||||
×
|
||||
{{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
|
||||
{{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }}
|
||||
/{{ "month" | i18n }}
|
||||
</span>
|
||||
<span>{{ 0 | currency: "$" }}</span>
|
||||
<span>{{
|
||||
organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb
|
||||
| currency: "$"
|
||||
}}</span>
|
||||
</p>
|
||||
<!-- secrets manager summary for monthly -->
|
||||
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
|
||||
|
|
|
@ -21,22 +21,27 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
|||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import {
|
||||
PaymentMethodType,
|
||||
PlanInterval,
|
||||
PlanType,
|
||||
ProductTierType,
|
||||
PlanInterval,
|
||||
} from "@bitwarden/common/billing/enums";
|
||||
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
import { TaxInfoComponent } from "../shared/tax-info.component";
|
||||
|
||||
|
@ -80,6 +85,7 @@ interface OnSuccessArgs {
|
|||
})
|
||||
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
|
||||
@ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent;
|
||||
|
||||
@Input() acceptingSponsorship = false;
|
||||
|
@ -155,6 +161,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
totalOpened: boolean = false;
|
||||
currentPlan: PlanResponse;
|
||||
|
||||
deprecateStripeSourcesAPI: boolean;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
|
@ -171,9 +179,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
private messagingService: MessagingService,
|
||||
private formBuilder: FormBuilder,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
|
||||
);
|
||||
|
||||
if (this.dialogParams.organizationId) {
|
||||
this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType);
|
||||
this.sub =
|
||||
|
@ -477,6 +491,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
|
||||
if (!selectedPlan.isAnnual) {
|
||||
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
|
||||
}
|
||||
return selectedPlan.PasswordManager.additionalStoragePricePerGb / 12;
|
||||
}
|
||||
|
||||
additionalServiceAccountTotal(plan: PlanResponse): number {
|
||||
if (!plan.SecretsManager.hasAdditionalServiceAccountOption || this.additionalServiceAccount) {
|
||||
return 0;
|
||||
|
@ -525,9 +546,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
|
||||
get total() {
|
||||
if (this.organization.useSecretsManager) {
|
||||
return this.passwordManagerSubtotal + this.secretsManagerSubtotal + this.taxCharges || 0;
|
||||
return (
|
||||
this.passwordManagerSubtotal +
|
||||
this.additionalStorageTotal(this.selectedPlan) +
|
||||
this.secretsManagerSubtotal +
|
||||
this.taxCharges || 0
|
||||
);
|
||||
}
|
||||
return this.passwordManagerSubtotal + this.taxCharges || 0;
|
||||
return (
|
||||
this.passwordManagerSubtotal +
|
||||
this.additionalStorageTotal(this.selectedPlan) +
|
||||
this.taxCharges || 0
|
||||
);
|
||||
}
|
||||
|
||||
get teamsStarterPlanIsAvailable() {
|
||||
|
@ -579,7 +609,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
changedCountry() {
|
||||
if (this.paymentComponent && this.taxComponent) {
|
||||
if (this.deprecateStripeSourcesAPI && this.paymentV2Component && this.taxComponent) {
|
||||
this.paymentV2Component.showBankAccount = this.taxComponent.country === "US";
|
||||
|
||||
if (
|
||||
!this.paymentV2Component.showBankAccount &&
|
||||
this.paymentV2Component.selected === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentV2Component.select(PaymentMethodType.Card);
|
||||
}
|
||||
} else if (this.paymentComponent && this.taxComponent) {
|
||||
this.paymentComponent!.hideBank = this.taxComponent?.taxFormGroup?.value.country !== "US";
|
||||
// Bank Account payments are only available for US customers
|
||||
if (
|
||||
|
@ -600,7 +639,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
|
||||
const doSubmit = async (): Promise<string> => {
|
||||
let orgId: string = null;
|
||||
orgId = await this.updateOrganization(orgId);
|
||||
orgId = await this.updateOrganization();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
|
@ -634,11 +673,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
this.dialogRef.close();
|
||||
};
|
||||
|
||||
private async updateOrganization(orgId: string) {
|
||||
private async updateOrganization() {
|
||||
const request = new OrganizationUpgradeRequest();
|
||||
if (this.selectedPlan.productTier !== ProductTierType.Families) {
|
||||
request.additionalSeats = this.organization.seats;
|
||||
}
|
||||
if (this.organization.maxStorageGb > this.selectedPlan.PasswordManager.baseStorageGb) {
|
||||
request.additionalStorageGb =
|
||||
this.organization.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb;
|
||||
}
|
||||
request.premiumAccessAddon =
|
||||
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
|
||||
this.formGroup.controls.premiumAccessAddon.value;
|
||||
|
@ -652,13 +695,33 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
|||
this.buildSecretsManagerRequest(request);
|
||||
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment) {
|
||||
const tokenResult = await this.paymentComponent.createPaymentToken();
|
||||
const paymentRequest = new PaymentRequest();
|
||||
paymentRequest.paymentToken = tokenResult[0];
|
||||
paymentRequest.paymentMethodType = tokenResult[1];
|
||||
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
|
||||
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
|
||||
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
|
||||
if (this.deprecateStripeSourcesAPI) {
|
||||
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
|
||||
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
|
||||
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
|
||||
updatePaymentMethodRequest.taxInformation = {
|
||||
country: this.taxComponent.country,
|
||||
postalCode: this.taxComponent.postalCode,
|
||||
taxId: this.taxComponent.taxId,
|
||||
line1: this.taxComponent.line1,
|
||||
line2: this.taxComponent.line2,
|
||||
city: this.taxComponent.city,
|
||||
state: this.taxComponent.state,
|
||||
};
|
||||
|
||||
await this.billingApiService.updateOrganizationPaymentMethod(
|
||||
this.organizationId,
|
||||
updatePaymentMethodRequest,
|
||||
);
|
||||
} else {
|
||||
const tokenResult = await this.paymentComponent.createPaymentToken();
|
||||
const paymentRequest = new PaymentRequest();
|
||||
paymentRequest.paymentToken = tokenResult[0];
|
||||
paymentRequest.paymentMethodType = tokenResult[1];
|
||||
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
|
||||
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
|
||||
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill pub/priv key if necessary
|
||||
|
|
|
@ -86,7 +86,7 @@ export class PaymentV2Component implements OnInit, OnDestroy {
|
|||
|
||||
/** Programmatically select the provided payment method. */
|
||||
select = (paymentMethod: PaymentMethodType) => {
|
||||
this.formGroup.value.paymentMethod = paymentMethod;
|
||||
this.formGroup.get("paymentMethod").patchValue(paymentMethod);
|
||||
};
|
||||
|
||||
protected submit = async () => {
|
||||
|
|
|
@ -157,7 +157,7 @@
|
|||
</button>
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="canEditCipher || !vaultBulkManagementActionEnabled"
|
||||
*ngIf="canManageCollection || !vaultBulkManagementActionEnabled"
|
||||
(click)="deleteCipher()"
|
||||
type="button"
|
||||
>
|
||||
|
|
|
@ -36,6 +36,7 @@ export class VaultCipherRowComponent implements OnInit {
|
|||
@Input() viewingOrgVault: boolean;
|
||||
@Input() canEditCipher: boolean;
|
||||
@Input() vaultBulkManagementActionEnabled: boolean;
|
||||
@Input() canManageCollection: boolean;
|
||||
|
||||
@Output() onEvent = new EventEmitter<VaultItemEvent>();
|
||||
|
||||
|
|
|
@ -133,6 +133,9 @@
|
|||
[collections]="allCollections"
|
||||
[checked]="selection.isSelected(item)"
|
||||
[canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled"
|
||||
[canManageCollection]="
|
||||
canManageCollection(item.cipher) && vaultBulkManagementActionEnabled
|
||||
"
|
||||
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled"
|
||||
(checkedToggled)="selection.toggle(item)"
|
||||
(onEvent)="event($event)"
|
||||
|
|
|
@ -48,6 +48,7 @@ export class VaultItemsComponent {
|
|||
@Input() addAccessToggle: boolean;
|
||||
@Input() restrictProviderAccess: boolean;
|
||||
@Input() vaultBulkManagementActionEnabled = false;
|
||||
@Input() activeCollection: CollectionView | undefined;
|
||||
|
||||
private _ciphers?: CipherView[] = [];
|
||||
@Input() get ciphers(): CipherView[] {
|
||||
|
@ -218,6 +219,33 @@ export class VaultItemsComponent {
|
|||
);
|
||||
}
|
||||
|
||||
protected canManageCollection(cipher: CipherView) {
|
||||
if (cipher.organizationId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for admin access in AC vault
|
||||
if (this.showAdminActions) {
|
||||
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
|
||||
|
||||
if (organization?.permissions.editAnyCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.activeCollection) {
|
||||
return this.activeCollection.manage;
|
||||
}
|
||||
|
||||
return this.allCollections
|
||||
.filter((c) => cipher.collectionIds.includes(c.id))
|
||||
.some((collection) => collection.manage);
|
||||
}
|
||||
|
||||
private refreshItems() {
|
||||
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
|
||||
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
|
||||
|
@ -294,20 +322,16 @@ export class VaultItemsComponent {
|
|||
|
||||
const hasPersonalItems = this.hasPersonalItems();
|
||||
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
|
||||
const organizations = Array.from(uniqueCipherOrgIds, (orgId) =>
|
||||
this.allOrganizations.find((o) => o.id === orgId),
|
||||
);
|
||||
|
||||
const canEditOrManageAllCiphers =
|
||||
organizations.length > 0 &&
|
||||
organizations.every((org) => org?.canEditAllCiphers(this.restrictProviderAccess));
|
||||
const canManageCollectionCiphers = this.selection.selected
|
||||
.filter((item) => item.cipher)
|
||||
.every(({ cipher }) => this.canManageCollection(cipher));
|
||||
|
||||
const canDeleteCollections = this.selection.selected
|
||||
.filter((item) => item.collection)
|
||||
.every((item) => item.collection && this.canDeleteCollection(item.collection));
|
||||
|
||||
const userCanDeleteAccess =
|
||||
(canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && canDeleteCollections;
|
||||
const userCanDeleteAccess = canManageCollectionCiphers && canDeleteCollections;
|
||||
|
||||
if (
|
||||
userCanDeleteAccess ||
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
|
||||
(onEvent)="onVaultItemsEvent($event)"
|
||||
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
|
||||
[activeCollection]="selectedCollection?.node"
|
||||
>
|
||||
</app-vault-items>
|
||||
<div
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html class="theme_light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1010" />
|
||||
<meta name="theme-color" content="#175DDC" />
|
||||
|
||||
<title>Bitwarden Web vault</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../images/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../images/icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../images/icons/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="../images/icons/safari-pinned-tab.svg" color="#175DDC" />
|
||||
<link rel="manifest" href="../manifest.json" />
|
||||
</head>
|
||||
|
||||
<body class="layout_frontend">
|
||||
<div class="tw-p-8 tw-flex">
|
||||
<img class="new-logo-themed" alt="Bitwarden" />
|
||||
<div class="spinner-container tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
|
||||
title="Loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,17 @@
|
|||
// This redirect connector is used to redirect users to the correct URL after they have been sent here from an email link.
|
||||
// The fragment contains the information needed to redirect the user to the correct page.
|
||||
// This is required because android app links couldn't properly handle the angular hash based route we originally had in the email link.
|
||||
window.addEventListener("load", () => {
|
||||
// ex: https://vault.bitwarden.com/redirect-connector.html#finish-signup?token=fakeToken&email=example%40example.com&fromEmail=true
|
||||
const currentUrl = new URL(window.location.href);
|
||||
|
||||
// Get the fragment (everything after the #)
|
||||
const fragment = currentUrl.hash.substring(1); // Remove the leading #
|
||||
|
||||
if (!fragment) {
|
||||
throw new Error("No fragment found in URL. Cannot determine redirect.");
|
||||
}
|
||||
|
||||
const newUrl = `${window.location.origin}/#/${fragment}`;
|
||||
window.location.href = newUrl;
|
||||
});
|
|
@ -111,6 +111,11 @@ const plugins = [
|
|||
filename: "sso-connector.html",
|
||||
chunks: ["connectors/sso"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/redirect.html",
|
||||
filename: "redirect-connector.html",
|
||||
chunks: ["connectors/redirect", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/captcha.html",
|
||||
filename: "captcha-connector.html",
|
||||
|
@ -325,6 +330,7 @@ const webpackConfig = {
|
|||
"connectors/sso": "./src/connectors/sso.ts",
|
||||
"connectors/captcha": "./src/connectors/captcha.ts",
|
||||
"connectors/duo-redirect": "./src/connectors/duo-redirect.ts",
|
||||
"connectors/redirect": "./src/connectors/redirect.ts",
|
||||
styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"],
|
||||
theme_head: "./src/theme.ts",
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { inject } from "@angular/core";
|
||||
import { Router, UrlTree } from "@angular/router";
|
||||
import { UrlTree, Router } from "@angular/router";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
|
@ -77,7 +77,7 @@ export type SingleUserDependency = {
|
|||
export type OnDependency = {
|
||||
/** The stream that controls emissions
|
||||
*/
|
||||
on$: Observable<void>;
|
||||
on$: Observable<any>;
|
||||
};
|
||||
|
||||
/** A pattern for types that emit when a dependency is `true`.
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { GenerationRequest } from "../../types";
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when an integration is invoked.
|
||||
*/
|
||||
export type IntegrationRequest = {
|
||||
/** @param website The domain of the website the requested integration is used
|
||||
* within. This should be set to `null` when the request is not specific
|
||||
* to any website.
|
||||
* @remarks this field contains sensitive data
|
||||
*/
|
||||
website: string | null;
|
||||
};
|
||||
export type IntegrationRequest = Partial<GenerationRequest>;
|
||||
|
|
|
@ -46,3 +46,20 @@ export type Constraints<T> = {
|
|||
|
||||
/** utility type for methods that evaluate constraints generically. */
|
||||
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
*/
|
||||
export type VaultItemRequest = {
|
||||
/** The domain of the website the requested credential is used
|
||||
* within. This should be set to `null` when the request is not specific
|
||||
* to any website.
|
||||
* @remarks this field contains sensitive data
|
||||
*/
|
||||
website: string | null;
|
||||
};
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
*/
|
||||
export type GenerationRequest = Partial<VaultItemRequest>;
|
||||
|
|
|
@ -4,41 +4,60 @@ import { ReactiveFormsModule } from "@angular/forms";
|
|||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import {
|
||||
CardComponent,
|
||||
CheckboxModule,
|
||||
ColorPasswordModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
InputModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
ToggleGroupModule,
|
||||
} from "@bitwarden/components";
|
||||
import { CredentialGeneratorService } from "@bitwarden/generator-core";
|
||||
import {
|
||||
createRandomizer,
|
||||
CredentialGeneratorService,
|
||||
Randomizer,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
|
||||
|
||||
/** Shared module containing generator component dependencies */
|
||||
@NgModule({
|
||||
imports: [SectionComponent, SectionHeaderComponent, CardComponent],
|
||||
imports: [CardComponent, SectionComponent, SectionHeaderComponent],
|
||||
exports: [
|
||||
CardComponent,
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
ColorPasswordModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
InputModule,
|
||||
ItemModule,
|
||||
JslibModule,
|
||||
JslibServicesModule,
|
||||
FormFieldModule,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
ColorPasswordModule,
|
||||
InputModule,
|
||||
CheckboxModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
CardComponent,
|
||||
ToggleGroupModule,
|
||||
],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: RANDOMIZER,
|
||||
useFactory: createRandomizer,
|
||||
deps: [CryptoService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CredentialGeneratorService,
|
||||
useClass: CredentialGeneratorService,
|
||||
deps: [StateProvider, PolicyService],
|
||||
deps: [RANDOMIZER, StateProvider, PolicyService],
|
||||
}),
|
||||
],
|
||||
declarations: [],
|
||||
|
|
|
@ -2,3 +2,4 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component";
|
|||
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
|
||||
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
|
||||
export { PasswordSettingsComponent } from "./password-settings.component";
|
||||
export { PasswordGeneratorComponent } from "./password-generator.component";
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
<bit-toggle-group
|
||||
fullWidth
|
||||
class="tw-mb-4"
|
||||
[selected]="credentialType$ | async"
|
||||
(selectedChange)="onCredentialTypeChanged($event)"
|
||||
attr.aria-label="{{ 'type' | i18n }}"
|
||||
>
|
||||
<bit-toggle value="password">
|
||||
{{ "password" | i18n }}
|
||||
</bit-toggle>
|
||||
<bit-toggle value="passphrase">
|
||||
{{ "passphrase" | i18n }}
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
<bit-card class="tw-flex tw-justify-between tw-mb-4">
|
||||
<div class="tw-grow">
|
||||
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
|
||||
</div>
|
||||
<div class="tw-space-x-1 tw-flex-none tw-w-4">
|
||||
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
|
||||
{{ "generatePassword" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
buttonType="main"
|
||||
[appCopyClick]="value$ | async"
|
||||
>
|
||||
{{ "copyPassword" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-card>
|
||||
<bit-password-settings
|
||||
class="tw-mt-6"
|
||||
*ngIf="(credentialType$ | async) === 'password'"
|
||||
[userId]="this.userId$ | async"
|
||||
(onUpdated)="generate$.next()"
|
||||
/>
|
||||
<bit-passphrase-settings
|
||||
class="tw-mt-6"
|
||||
*ngIf="(credentialType$ | async) === 'passphrase'"
|
||||
[userId]="this.userId$ | async"
|
||||
(onUpdated)="generate$.next()"
|
||||
/>
|
|
@ -0,0 +1,117 @@
|
|||
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core";
|
||||
import { GeneratedCredential } from "@bitwarden/generator-history";
|
||||
|
||||
import { DependenciesModule } from "./dependencies";
|
||||
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
|
||||
import { PasswordSettingsComponent } from "./password-settings.component";
|
||||
|
||||
/** Options group for passwords */
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "bit-password-generator",
|
||||
templateUrl: "password-generator.component.html",
|
||||
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
|
||||
})
|
||||
export class PasswordGeneratorComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private generatorService: CredentialGeneratorService,
|
||||
private accountService: AccountService,
|
||||
private zone: NgZone,
|
||||
) {}
|
||||
|
||||
/** Binds the passphrase component to a specific user's settings.
|
||||
* When this input is not provided, the form binds to the active
|
||||
* user
|
||||
*/
|
||||
@Input()
|
||||
userId: UserId | null;
|
||||
|
||||
/** tracks the currently selected credential type */
|
||||
protected credentialType$ = new BehaviorSubject<GeneratorType>("password");
|
||||
|
||||
/** Emits the last generated value. */
|
||||
protected readonly value$ = new BehaviorSubject<string>("");
|
||||
|
||||
/** Emits when the userId changes */
|
||||
protected readonly userId$ = new BehaviorSubject<UserId>(null);
|
||||
|
||||
/** Emits when a new credential is requested */
|
||||
protected readonly generate$ = new Subject<void>();
|
||||
|
||||
/** Tracks changes to the selected credential type
|
||||
* @param type the new credential type
|
||||
*/
|
||||
protected onCredentialTypeChanged(type: GeneratorType) {
|
||||
if (this.credentialType$.value !== type) {
|
||||
this.credentialType$.next(type);
|
||||
this.generate$.next();
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits credentials created from a generation request. */
|
||||
@Output()
|
||||
readonly onGenerated = new EventEmitter<GeneratedCredential>();
|
||||
|
||||
async ngOnInit() {
|
||||
if (this.userId) {
|
||||
this.userId$.next(this.userId);
|
||||
} else {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
map((acct) => acct.id),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroyed),
|
||||
)
|
||||
.subscribe(this.userId$);
|
||||
}
|
||||
|
||||
this.credentialType$
|
||||
.pipe(
|
||||
switchMap((type) => this.typeToGenerator$(type)),
|
||||
takeUntil(this.destroyed),
|
||||
)
|
||||
.subscribe((generated) => {
|
||||
// update subjects within the angular zone so that the
|
||||
// template bindings refresh immediately
|
||||
this.zone.run(() => {
|
||||
this.onGenerated.next(generated);
|
||||
this.value$.next(generated.credential);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private typeToGenerator$(type: GeneratorType) {
|
||||
const dependencies = {
|
||||
on$: this.generate$,
|
||||
userId$: this.userId$,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "password":
|
||||
return this.generatorService.generate$(Generators.Password, dependencies);
|
||||
|
||||
case "passphrase":
|
||||
return this.generatorService.generate$(Generators.Passphrase, dependencies);
|
||||
default:
|
||||
throw new Error(`Invalid generator type: "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly destroyed = new Subject<void>();
|
||||
ngOnDestroy(): void {
|
||||
// tear down subscriptions
|
||||
this.destroyed.complete();
|
||||
|
||||
// finalize subjects
|
||||
this.generate$.complete();
|
||||
this.value$.complete();
|
||||
|
||||
// finalize component bindings
|
||||
this.onGenerated.complete();
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
import { Randomizer } from "../abstractions";
|
||||
import { PasswordRandomizer } from "../engine";
|
||||
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage";
|
||||
import {
|
||||
CredentialGenerator,
|
||||
PassphraseGenerationOptions,
|
||||
PassphraseGeneratorPolicy,
|
||||
PasswordGenerationOptions,
|
||||
|
@ -14,6 +17,12 @@ import { DefaultPasswordGenerationOptions } from "./default-password-generation-
|
|||
import { Policies } from "./policies";
|
||||
|
||||
const PASSPHRASE = Object.freeze({
|
||||
category: "passphrase",
|
||||
engine: {
|
||||
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> {
|
||||
return new PasswordRandomizer(randomizer);
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
initial: DefaultPassphraseGenerationOptions,
|
||||
constraints: {
|
||||
|
@ -32,6 +41,12 @@ const PASSPHRASE = Object.freeze({
|
|||
>);
|
||||
|
||||
const PASSWORD = Object.freeze({
|
||||
category: "password",
|
||||
engine: {
|
||||
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> {
|
||||
return new PasswordRandomizer(randomizer);
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
initial: DefaultPasswordGenerationOptions,
|
||||
constraints: {
|
||||
|
|
|
@ -335,4 +335,40 @@ describe("PasswordRandomizer", () => {
|
|||
expect(result).toEqual("foo-foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate", () => {
|
||||
it("processes password generation options", async () => {
|
||||
const password = new PasswordRandomizer(randomizer);
|
||||
|
||||
const result = await password.generate(
|
||||
{},
|
||||
{
|
||||
length: 10,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.category).toEqual("password");
|
||||
});
|
||||
|
||||
it("processes passphrase generation options", async () => {
|
||||
const password = new PasswordRandomizer(randomizer);
|
||||
|
||||
const result = await password.generate(
|
||||
{},
|
||||
{
|
||||
numWords: 10,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.category).toEqual("passphrase");
|
||||
});
|
||||
|
||||
it("throws when it cannot recognize the options type", async () => {
|
||||
const password = new PasswordRandomizer(randomizer);
|
||||
|
||||
const result = password.generate({}, {});
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
|
||||
import { GenerationRequest } from "@bitwarden/common/tools/types";
|
||||
|
||||
import {
|
||||
CredentialGenerator,
|
||||
GeneratedCredential,
|
||||
PassphraseGenerationOptions,
|
||||
PasswordGenerationOptions,
|
||||
} from "../types";
|
||||
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest } from "../util";
|
||||
|
||||
import { Randomizer } from "./abstractions";
|
||||
import { Ascii } from "./data";
|
||||
import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types";
|
||||
|
||||
/** Generation algorithms that produce randomized secrets */
|
||||
export class PasswordRandomizer {
|
||||
export class PasswordRandomizer
|
||||
implements
|
||||
CredentialGenerator<PassphraseGenerationOptions>,
|
||||
CredentialGenerator<PasswordGenerationOptions>
|
||||
{
|
||||
/** Instantiates the password randomizer
|
||||
* @param random data source for random data
|
||||
* @param randomizer data source for random data
|
||||
*/
|
||||
constructor(private randomizer: Randomizer) {}
|
||||
|
||||
|
@ -52,6 +65,41 @@ export class PasswordRandomizer {
|
|||
|
||||
return wordList.join(request.separator);
|
||||
}
|
||||
|
||||
generate(
|
||||
request: GenerationRequest,
|
||||
settings: PasswordGenerationOptions,
|
||||
): Promise<GeneratedCredential>;
|
||||
generate(
|
||||
request: GenerationRequest,
|
||||
settings: PassphraseGenerationOptions,
|
||||
): Promise<GeneratedCredential>;
|
||||
async generate(
|
||||
_request: GenerationRequest,
|
||||
settings: PasswordGenerationOptions | PassphraseGenerationOptions,
|
||||
) {
|
||||
if (isPasswordGenerationOptions(settings)) {
|
||||
const request = optionsToRandomAsciiRequest(settings);
|
||||
const password = await this.randomAscii(request);
|
||||
|
||||
return new GeneratedCredential(password, "password", Date.now());
|
||||
} else if (isPassphraseGenerationOptions(settings)) {
|
||||
const request = optionsToEffWordListRequest(settings);
|
||||
const passphrase = await this.randomEffLongWords(request);
|
||||
|
||||
return new GeneratedCredential(passphrase, "passphrase", Date.now());
|
||||
}
|
||||
|
||||
throw new Error("Invalid settings received by generator.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions {
|
||||
return "length" in (settings ?? {});
|
||||
}
|
||||
|
||||
function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions {
|
||||
return "numWords" in (settings ?? {});
|
||||
}
|
||||
|
||||
// given a generator request, convert each of its `number | undefined` properties
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
@ -8,9 +8,14 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st
|
|||
import { Constraints } from "@bitwarden/common/tools/types";
|
||||
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
|
||||
import { PolicyEvaluator } from "../abstractions";
|
||||
import { CredentialGeneratorConfiguration } from "../types";
|
||||
import {
|
||||
FakeStateProvider,
|
||||
FakeAccountService,
|
||||
awaitAsync,
|
||||
ObservableTracker,
|
||||
} from "../../../../../common/spec";
|
||||
import { PolicyEvaluator, Randomizer } from "../abstractions";
|
||||
import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types";
|
||||
|
||||
import { CredentialGeneratorService } from "./credential-generator.service";
|
||||
|
||||
|
@ -34,8 +39,23 @@ const somePolicy = new Policy({
|
|||
enabled: true,
|
||||
});
|
||||
|
||||
const SomeTime = new Date(1);
|
||||
const SomeCategory = "passphrase";
|
||||
|
||||
// fake the configuration
|
||||
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
|
||||
category: SomeCategory,
|
||||
engine: {
|
||||
create: (randomizer) => {
|
||||
return {
|
||||
generate: (request, settings) => {
|
||||
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
|
||||
const result = new GeneratedCredential(credential, SomeCategory, SomeTime);
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
initial: { foo: "initial" },
|
||||
constraints: { foo: {} },
|
||||
|
@ -87,6 +107,9 @@ const accountService = new FakeAccountService({
|
|||
// fake state
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
// fake randomizer
|
||||
const randomizer = mock<Randomizer>();
|
||||
|
||||
describe("CredentialGeneratorService", () => {
|
||||
beforeEach(async () => {
|
||||
await accountService.switchAccount(SomeUser);
|
||||
|
@ -94,10 +117,242 @@ describe("CredentialGeneratorService", () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("generate$", () => {
|
||||
it("emits a generation for the active user when subscribed", async () => {
|
||||
const settings = { foo: "value" };
|
||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||
|
||||
const result = await generated.expectEmission();
|
||||
|
||||
expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime));
|
||||
});
|
||||
|
||||
it("follows the active user", async () => {
|
||||
const someSettings = { foo: "some value" };
|
||||
const anotherSettings = { foo: "another value" };
|
||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||
|
||||
await accountService.switchAccount(AnotherUser);
|
||||
await generated.pauseUntilReceived(2);
|
||||
generated.unsubscribe();
|
||||
|
||||
expect(generated.emissions).toEqual([
|
||||
new GeneratedCredential("some value", SomeCategory, SomeTime),
|
||||
new GeneratedCredential("another value", SomeCategory, SomeTime),
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a generation when the settings change", async () => {
|
||||
const someSettings = { foo: "some value" };
|
||||
const anotherSettings = { foo: "another value" };
|
||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||
|
||||
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
|
||||
await generated.pauseUntilReceived(2);
|
||||
generated.unsubscribe();
|
||||
|
||||
expect(generated.emissions).toEqual([
|
||||
new GeneratedCredential("some value", SomeCategory, SomeTime),
|
||||
new GeneratedCredential("another value", SomeCategory, SomeTime),
|
||||
]);
|
||||
});
|
||||
|
||||
// FIXME: test these when the fake state provider can create the required emissions
|
||||
it.todo("errors when the settings error");
|
||||
it.todo("completes when the settings complete");
|
||||
|
||||
it("includes `website$`'s last emitted value", async () => {
|
||||
const settings = { foo: "value" };
|
||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const website$ = new BehaviorSubject("some website");
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
|
||||
|
||||
const result = await generated.expectEmission();
|
||||
|
||||
expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime));
|
||||
});
|
||||
|
||||
it("errors when `website$` errors", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const website$ = new BehaviorSubject("some website");
|
||||
let error = null;
|
||||
|
||||
generator.generate$(SomeConfiguration, { website$ }).subscribe({
|
||||
error: (e: unknown) => {
|
||||
error = e;
|
||||
},
|
||||
});
|
||||
website$.error({ some: "error" });
|
||||
await awaitAsync();
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("completes when `website$` completes", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const website$ = new BehaviorSubject("some website");
|
||||
let completed = false;
|
||||
|
||||
generator.generate$(SomeConfiguration, { website$ }).subscribe({
|
||||
complete: () => {
|
||||
completed = true;
|
||||
},
|
||||
});
|
||||
website$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits a generation for a specific user when `user$` supplied", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
||||
|
||||
const result = await generated.expectEmission();
|
||||
|
||||
expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime));
|
||||
});
|
||||
|
||||
it("emits a generation for a specific user when `user$` emits", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.pipe(filter((u) => !!u));
|
||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
||||
|
||||
userId.next(AnotherUser);
|
||||
const result = await generated.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual([
|
||||
new GeneratedCredential("value", SomeCategory, SomeTime),
|
||||
new GeneratedCredential("another", SomeCategory, SomeTime),
|
||||
]);
|
||||
});
|
||||
|
||||
it("errors when `user$` errors", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(SomeUser);
|
||||
let error = null;
|
||||
|
||||
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
|
||||
error: (e: unknown) => {
|
||||
error = e;
|
||||
},
|
||||
});
|
||||
userId$.error({ some: "error" });
|
||||
await awaitAsync();
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("completes when `user$` completes", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(SomeUser);
|
||||
let completed = false;
|
||||
|
||||
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
|
||||
complete: () => {
|
||||
completed = true;
|
||||
},
|
||||
});
|
||||
userId$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits a generation only when `on$` emits", async () => {
|
||||
// This test breaks from arrange/act/assert because it is testing causality
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const on$ = new Subject<void>();
|
||||
const results: any[] = [];
|
||||
|
||||
// confirm no emission during subscription
|
||||
const sub = generator
|
||||
.generate$(SomeConfiguration, { on$ })
|
||||
.subscribe((result) => results.push(result));
|
||||
await awaitAsync();
|
||||
expect(results.length).toEqual(0);
|
||||
|
||||
// confirm forwarded emission
|
||||
on$.next();
|
||||
await awaitAsync();
|
||||
expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]);
|
||||
|
||||
// confirm updating settings does not cause an emission
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser);
|
||||
await awaitAsync();
|
||||
expect(results.length).toBe(1);
|
||||
|
||||
// confirm forwarded emission takes latest value
|
||||
on$.next();
|
||||
await awaitAsync();
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(results).toEqual([
|
||||
new GeneratedCredential("value", SomeCategory, SomeTime),
|
||||
new GeneratedCredential("next", SomeCategory, SomeTime),
|
||||
]);
|
||||
});
|
||||
|
||||
it("errors when `on$` errors", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const on$ = new Subject<void>();
|
||||
let error: any = null;
|
||||
|
||||
// confirm no emission during subscription
|
||||
generator.generate$(SomeConfiguration, { on$ }).subscribe({
|
||||
error: (e: unknown) => {
|
||||
error = e;
|
||||
},
|
||||
});
|
||||
on$.error({ some: "error" });
|
||||
await awaitAsync();
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("completes when `on$` completes", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const on$ = new Subject<void>();
|
||||
let complete = false;
|
||||
|
||||
// confirm no emission during subscription
|
||||
generator.generate$(SomeConfiguration, { on$ }).subscribe({
|
||||
complete: () => {
|
||||
complete = true;
|
||||
},
|
||||
});
|
||||
on$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(complete).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings$", () => {
|
||||
it("defaults to the configuration's initial settings if settings aren't found", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
|
||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||
|
||||
|
@ -107,7 +362,7 @@ describe("CredentialGeneratorService", () => {
|
|||
it("reads from the active user's configuration-defined storage", async () => {
|
||||
const settings = { foo: "value" };
|
||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
|
||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||
|
||||
|
@ -119,7 +374,7 @@ describe("CredentialGeneratorService", () => {
|
|||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||
const policy$ = new BehaviorSubject([somePolicy]);
|
||||
policyService.getAll$.mockReturnValue(policy$);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
|
||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||
|
||||
|
@ -131,7 +386,7 @@ describe("CredentialGeneratorService", () => {
|
|||
const anotherSettings = { foo: "another" };
|
||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const results: any = [];
|
||||
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
|
||||
|
||||
|
@ -148,7 +403,7 @@ describe("CredentialGeneratorService", () => {
|
|||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||
const anotherSettings = { foo: "another" };
|
||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
||||
|
||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
|
||||
|
@ -161,7 +416,7 @@ describe("CredentialGeneratorService", () => {
|
|||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||
const anotherSettings = { foo: "another" };
|
||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
const results: any = [];
|
||||
|
@ -180,7 +435,7 @@ describe("CredentialGeneratorService", () => {
|
|||
|
||||
it("errors when the arbitrary user's stream errors", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
let error = null;
|
||||
|
@ -198,7 +453,7 @@ describe("CredentialGeneratorService", () => {
|
|||
|
||||
it("completes when the arbitrary user's stream completes", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
let completed = false;
|
||||
|
@ -216,7 +471,7 @@ describe("CredentialGeneratorService", () => {
|
|||
|
||||
it("ignores repeated arbitrary user emissions", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
let count = 0;
|
||||
|
@ -240,7 +495,7 @@ describe("CredentialGeneratorService", () => {
|
|||
describe("settings", () => {
|
||||
it("writes to the user's state", async () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
|
||||
|
||||
subject.next({ foo: "next value" });
|
||||
|
@ -253,7 +508,7 @@ describe("CredentialGeneratorService", () => {
|
|||
it("waits for the user to become available", async () => {
|
||||
const singleUserId = new BehaviorSubject(null);
|
||||
const singleUserId$ = singleUserId.asObservable();
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
|
||||
let completed = false;
|
||||
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
|
||||
|
@ -271,7 +526,7 @@ describe("CredentialGeneratorService", () => {
|
|||
|
||||
describe("policy$", () => {
|
||||
it("creates a disabled policy evaluator when there is no policy", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||
|
||||
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
||||
|
@ -281,7 +536,7 @@ describe("CredentialGeneratorService", () => {
|
|||
});
|
||||
|
||||
it("creates an active policy evaluator when there is a policy", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||
const policy$ = new BehaviorSubject([somePolicy]);
|
||||
policyService.getAll$.mockReturnValue(policy$);
|
||||
|
@ -293,7 +548,7 @@ describe("CredentialGeneratorService", () => {
|
|||
});
|
||||
|
||||
it("follows policy emissions", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
||||
|
@ -316,7 +571,7 @@ describe("CredentialGeneratorService", () => {
|
|||
});
|
||||
|
||||
it("follows user emissions", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
|
||||
|
@ -340,7 +595,7 @@ describe("CredentialGeneratorService", () => {
|
|||
});
|
||||
|
||||
it("errors when the user errors", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
const expectedError = { some: "error" };
|
||||
|
@ -358,7 +613,7 @@ describe("CredentialGeneratorService", () => {
|
|||
});
|
||||
|
||||
it("completes when the user completes", async () => {
|
||||
const generator = new CredentialGeneratorService(stateProvider, policyService);
|
||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||
const userId = new BehaviorSubject(SomeUser);
|
||||
const userId$ = userId.asObservable();
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
endWith,
|
||||
filter,
|
||||
|
@ -8,32 +10,84 @@ import {
|
|||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
race,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies";
|
||||
import {
|
||||
OnDependency,
|
||||
SingleUserDependency,
|
||||
UserDependency,
|
||||
} from "@bitwarden/common/tools/dependencies";
|
||||
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||
import { Constraints } from "@bitwarden/common/tools/types";
|
||||
|
||||
import { PolicyEvaluator } from "../abstractions";
|
||||
import { PolicyEvaluator, Randomizer } from "../abstractions";
|
||||
import { mapPolicyToEvaluatorV2 } from "../rx";
|
||||
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
|
||||
|
||||
type Policy$Dependencies = UserDependency;
|
||||
type Settings$Dependencies = Partial<UserDependency>;
|
||||
type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & {
|
||||
/** Emits the active website when subscribed.
|
||||
*
|
||||
* The generator does not respond to emissions of this interface;
|
||||
* If it is provided, the generator blocks until a value becomes available.
|
||||
* When `website$` is omitted, the generator uses the empty string instead.
|
||||
* When `website$` completes, the generator completes.
|
||||
* When `website$` errors, the generator forwards the error.
|
||||
*/
|
||||
website$?: Observable<string>;
|
||||
};
|
||||
// FIXME: once the modernization is complete, switch the type parameters
|
||||
// in `PolicyEvaluator<P, S>` and bake-in the constraints type.
|
||||
type Evaluator<Settings, Policy> = PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
|
||||
|
||||
export class CredentialGeneratorService {
|
||||
constructor(
|
||||
private randomizer: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private policyService: PolicyService,
|
||||
) {}
|
||||
|
||||
/** Generates a stream of credentials
|
||||
* @param configuration determines which generator's settings are loaded
|
||||
* @param dependencies.on$ when specified, a new credential is emitted when
|
||||
* this emits. Otherwise, a new credential is emitted when the settings
|
||||
* update.
|
||||
*/
|
||||
generate$<Settings, Policy>(
|
||||
configuration: Readonly<Configuration<Settings, Policy>>,
|
||||
dependencies?: Generate$Dependencies,
|
||||
) {
|
||||
// instantiate the engine
|
||||
const engine = configuration.engine.create(this.randomizer);
|
||||
|
||||
// stream blocks until all of these values are received
|
||||
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
|
||||
const request$ = website$.pipe(map((website) => ({ website })));
|
||||
const settings$ = this.settings$(configuration, dependencies);
|
||||
|
||||
// monitor completion
|
||||
const requestComplete$ = request$.pipe(ignoreElements(), endWith(true));
|
||||
const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
|
||||
const complete$ = race(requestComplete$, settingsComplete$);
|
||||
|
||||
// generation proper
|
||||
const generate$ = (dependencies?.on$ ?? settings$).pipe(
|
||||
withLatestFrom(request$, settings$),
|
||||
concatMap(([, request, settings]) => engine.generate(request, settings)),
|
||||
takeUntil(complete$),
|
||||
);
|
||||
|
||||
return generate$;
|
||||
}
|
||||
|
||||
/** Get the settings for the provided configuration
|
||||
* @param configuration determines which generator's settings are loaded
|
||||
* @param dependencies.userId$ identifies the user to which the settings are bound.
|
||||
|
@ -82,7 +136,7 @@ export class CredentialGeneratorService {
|
|||
* @remarks the subject enforces policy for the settings
|
||||
*/
|
||||
async settings<Settings, Policy>(
|
||||
configuration: Configuration<Settings, Policy>,
|
||||
configuration: Readonly<Configuration<Settings, Policy>>,
|
||||
dependencies: SingleUserDependency,
|
||||
) {
|
||||
const userId = await firstValueFrom(
|
||||
|
|
|
@ -17,7 +17,7 @@ import { PASSPHRASE_SETTINGS } from "./storage";
|
|||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("Password generation strategy", () => {
|
||||
describe("Passphrase generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it("should map to the policy evaluator", async () => {
|
||||
const strategy = new PassphraseGeneratorStrategy(null, null);
|
||||
|
|
|
@ -2,11 +2,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
|||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data";
|
||||
import { DefaultPassphraseGenerationOptions, Policies } from "../data";
|
||||
import { PasswordRandomizer } from "../engine";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
||||
import { observe$PerUserId, sharedStateByUserId } from "../util";
|
||||
import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util";
|
||||
|
||||
import { PASSPHRASE_SETTINGS } from "./storage";
|
||||
|
||||
|
@ -33,13 +33,7 @@ export class PassphraseGeneratorStrategy
|
|||
|
||||
// algorithm
|
||||
async generate(options: PassphraseGenerationOptions): Promise<string> {
|
||||
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
|
||||
const request = {
|
||||
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
|
||||
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
|
||||
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
|
||||
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
|
||||
};
|
||||
const request = optionsToEffWordListRequest(options);
|
||||
|
||||
return this.randomizer.randomEffLongWords(request);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
|
|||
import { PasswordRandomizer } from "../engine";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
|
||||
import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
|
||||
import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util";
|
||||
|
||||
import { PASSWORD_SETTINGS } from "./storage";
|
||||
|
||||
|
@ -32,62 +32,7 @@ export class PasswordGeneratorStrategy
|
|||
|
||||
// algorithm
|
||||
async generate(options: PasswordGenerationOptions): Promise<string> {
|
||||
// converts password generation option sets, which are defined by
|
||||
// an "enabled" and "quantity" parameter, to the password engine's
|
||||
// parameters, which represent disabled options as `undefined`
|
||||
// properties.
|
||||
function process(
|
||||
// values read from the options
|
||||
enabled: boolean,
|
||||
quantity: number,
|
||||
// value used if an option is missing
|
||||
defaultEnabled: boolean,
|
||||
defaultQuantity: number,
|
||||
) {
|
||||
const isEnabled = enabled ?? defaultEnabled;
|
||||
const actualQuantity = quantity ?? defaultQuantity;
|
||||
const result = isEnabled ? actualQuantity : undefined;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const request = {
|
||||
uppercase: process(
|
||||
options.uppercase,
|
||||
options.minUppercase,
|
||||
DefaultPasswordGenerationOptions.uppercase,
|
||||
DefaultPasswordGenerationOptions.minUppercase,
|
||||
),
|
||||
lowercase: process(
|
||||
options.lowercase,
|
||||
options.minLowercase,
|
||||
DefaultPasswordGenerationOptions.lowercase,
|
||||
DefaultPasswordGenerationOptions.minLowercase,
|
||||
),
|
||||
digits: process(
|
||||
options.number,
|
||||
options.minNumber,
|
||||
DefaultPasswordGenerationOptions.number,
|
||||
DefaultPasswordGenerationOptions.minNumber,
|
||||
),
|
||||
special: process(
|
||||
options.special,
|
||||
options.minSpecial,
|
||||
DefaultPasswordGenerationOptions.special,
|
||||
DefaultPasswordGenerationOptions.minSpecial,
|
||||
),
|
||||
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
|
||||
all: 0,
|
||||
};
|
||||
|
||||
// engine represents character sets as "include only"; you assert how many all
|
||||
// characters there can be rather than a total length. This conversion has
|
||||
// the character classes win, so that the result is always consistent with policy
|
||||
// minimums.
|
||||
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
|
||||
const remaining = (options.length ?? 0) - required;
|
||||
request.all = Math.max(remaining, 0);
|
||||
|
||||
const request = optionsToRandomAsciiRequest(options);
|
||||
const result = await this.randomizer.randomAscii(request);
|
||||
|
||||
return result;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
/** Kinds of credentials that can be stored by the history service
|
||||
* password - a secret consisting of arbitrary characters used to authenticate a user
|
||||
* passphrase - a secret consisting of words used to authenticate a user
|
||||
*/
|
||||
export type CredentialCategory = "password" | "passphrase";
|
|
@ -1,9 +1,29 @@
|
|||
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { Constraints } from "@bitwarden/common/tools/types";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { PolicyConfiguration } from "../types";
|
||||
|
||||
import { CredentialCategory } from "./credential-category";
|
||||
import { CredentialGenerator } from "./credential-generator";
|
||||
|
||||
export type CredentialGeneratorConfiguration<Settings, Policy> = {
|
||||
/** Category describing usage of the credential generated by this configuration
|
||||
*/
|
||||
category: CredentialCategory;
|
||||
|
||||
/** An algorithm that generates credentials when ran. */
|
||||
engine: {
|
||||
/** Factory for the generator
|
||||
*/
|
||||
// FIXME: note that this erases the engine's type so that credentials are
|
||||
// generated uniformly. This property needs to be maintained for
|
||||
// the credential generator, but engine configurations should return
|
||||
// the underlying type. `create` may be able to do double-duty w/ an
|
||||
// engine definition if `CredentialGenerator` can be made covariant.
|
||||
create: (randomizer: Randomizer) => CredentialGenerator<Settings>;
|
||||
};
|
||||
/** Defines the stored parameters for credential generation */
|
||||
settings: {
|
||||
/** value used when an account's settings haven't been initialized */
|
||||
initial: Readonly<Partial<Settings>>;
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { GenerationRequest } from "@bitwarden/common/tools/types";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
|
||||
/** An algorithm that generates credentials. */
|
||||
export type CredentialGenerator<Settings> = {
|
||||
/** Generates a credential
|
||||
* @param request runtime parameters
|
||||
* @param settings stored parameters
|
||||
*/
|
||||
generate: (request: GenerationRequest, settings: Settings) => Promise<GeneratedCredential>;
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
import { CredentialCategory, GeneratedCredential } from ".";
|
||||
|
||||
describe("GeneratedCredential", () => {
|
||||
describe("constructor", () => {
|
||||
it("assigns credential", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.credential).toEqual("example");
|
||||
});
|
||||
|
||||
it("assigns category", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.category).toEqual("passphrase");
|
||||
});
|
||||
|
||||
it("passes through date parameters", () => {
|
||||
const result = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
|
||||
it("converts numeric dates to Dates", () => {
|
||||
const result = new GeneratedCredential("example", "password", 100);
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
});
|
||||
|
||||
it("toJSON converts from a credential into a JSON object", () => {
|
||||
const credential = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
const result = credential.toJSON();
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password" as CredentialCategory,
|
||||
generationDate: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("fromJSON converts Json objects into credentials", () => {
|
||||
const jsonValue = {
|
||||
credential: "example",
|
||||
category: "password" as CredentialCategory,
|
||||
generationDate: 100,
|
||||
};
|
||||
|
||||
const result = GeneratedCredential.fromJSON(jsonValue);
|
||||
|
||||
expect(result).toBeInstanceOf(GeneratedCredential);
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password",
|
||||
generationDate: new Date(100),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CredentialCategory } from "./credential-category";
|
||||
|
||||
/** A credential generation result */
|
||||
export class GeneratedCredential {
|
||||
/**
|
||||
* Instantiates a generated credential
|
||||
* @param credential The value of the generated credential (e.g. a password)
|
||||
* @param category The kind of credential
|
||||
* @param generationDate The date that the credential was generated.
|
||||
* Numeric values should are interpreted using {@link Date.valueOf}
|
||||
* semantics.
|
||||
*/
|
||||
constructor(
|
||||
readonly credential: string,
|
||||
readonly category: CredentialCategory,
|
||||
generationDate: Date | number,
|
||||
) {
|
||||
if (typeof generationDate === "number") {
|
||||
this.generationDate = new Date(generationDate);
|
||||
} else {
|
||||
this.generationDate = generationDate;
|
||||
}
|
||||
}
|
||||
|
||||
/** The date that the credential was generated */
|
||||
generationDate: Date;
|
||||
|
||||
/** Constructs a credential from its `toJSON` representation */
|
||||
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
|
||||
return new GeneratedCredential(
|
||||
jsonValue.credential,
|
||||
jsonValue.category,
|
||||
jsonValue.generationDate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Serializes a credential to a JSON-compatible object */
|
||||
toJSON() {
|
||||
return {
|
||||
credential: this.credential,
|
||||
category: this.category,
|
||||
generationDate: this.generationDate.valueOf(),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
export * from "./boundary";
|
||||
export * from "./catchall-generator-options";
|
||||
export * from "./credential-category";
|
||||
export * from "./credential-generator";
|
||||
export * from "./credential-generator-configuration";
|
||||
export * from "./eff-username-generator-options";
|
||||
export * from "./forwarder-options";
|
||||
export * from "./generated-credential";
|
||||
export * from "./generator-options";
|
||||
export * from "./generator-type";
|
||||
export * from "./no-policy";
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { sum } from "./util";
|
||||
import { DefaultPassphraseGenerationOptions } from "./data";
|
||||
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util";
|
||||
|
||||
describe("sum", () => {
|
||||
it("returns 0 when the list is empty", () => {
|
||||
|
@ -15,3 +16,411 @@ describe("sum", () => {
|
|||
expect(sum(1, 2, 3)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionsToRandomAsciiRequest", () => {
|
||||
it("should map options", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 20,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 1,
|
||||
minLowercase: 2,
|
||||
minNumber: 3,
|
||||
minSpecial: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 10,
|
||||
uppercase: 1,
|
||||
lowercase: 2,
|
||||
digits: 3,
|
||||
special: 4,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable uppercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 3,
|
||||
ambiguous: true,
|
||||
uppercase: false,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumber: 1,
|
||||
minSpecial: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: undefined,
|
||||
lowercase: 1,
|
||||
digits: 1,
|
||||
special: 1,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable lowercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 3,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: false,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumber: 1,
|
||||
minSpecial: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 1,
|
||||
lowercase: undefined,
|
||||
digits: 1,
|
||||
special: 1,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable digits", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 3,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: false,
|
||||
special: true,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumber: 1,
|
||||
minSpecial: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 1,
|
||||
lowercase: 1,
|
||||
digits: undefined,
|
||||
special: 1,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable special", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 3,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: false,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumber: 1,
|
||||
minSpecial: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 1,
|
||||
lowercase: 1,
|
||||
digits: 1,
|
||||
special: undefined,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should override length with minimums", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 20,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 1,
|
||||
minLowercase: 2,
|
||||
minNumber: 3,
|
||||
minSpecial: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 10,
|
||||
uppercase: 1,
|
||||
lowercase: 2,
|
||||
digits: 3,
|
||||
special: 4,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default uppercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 2,
|
||||
ambiguous: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 2,
|
||||
minLowercase: 0,
|
||||
minNumber: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 2,
|
||||
lowercase: 0,
|
||||
digits: 0,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default lowercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 0,
|
||||
minLowercase: 2,
|
||||
minNumber: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 2,
|
||||
digits: 0,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default number", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
special: true,
|
||||
minUppercase: 0,
|
||||
minLowercase: 0,
|
||||
minNumber: 2,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 0,
|
||||
digits: 2,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default special", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
minUppercase: 0,
|
||||
minLowercase: 0,
|
||||
minNumber: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 0,
|
||||
digits: 0,
|
||||
special: undefined,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default minUppercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minLowercase: 0,
|
||||
minNumber: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 1,
|
||||
lowercase: 0,
|
||||
digits: 0,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default minLowercase", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 0,
|
||||
minNumber: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 1,
|
||||
digits: 0,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default minNumber", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 0,
|
||||
minLowercase: 0,
|
||||
minSpecial: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 0,
|
||||
digits: 1,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default minSpecial", async () => {
|
||||
const result = optionsToRandomAsciiRequest({
|
||||
length: 0,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
minUppercase: 0,
|
||||
minLowercase: 0,
|
||||
minNumber: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
all: 0,
|
||||
uppercase: 0,
|
||||
lowercase: 0,
|
||||
digits: 0,
|
||||
special: 0,
|
||||
ambiguous: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionsToEffWordListRequest", () => {
|
||||
it("should map options", async () => {
|
||||
const result = optionsToEffWordListRequest({
|
||||
numWords: 4,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
wordSeparator: "!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
numberOfWords: 4,
|
||||
capitalize: true,
|
||||
number: true,
|
||||
separator: "!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should default numWords", async () => {
|
||||
const result = optionsToEffWordListRequest({
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
wordSeparator: "!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
numberOfWords: DefaultPassphraseGenerationOptions.numWords,
|
||||
capitalize: true,
|
||||
number: true,
|
||||
separator: "!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should default capitalize", async () => {
|
||||
const result = optionsToEffWordListRequest({
|
||||
numWords: 4,
|
||||
includeNumber: true,
|
||||
wordSeparator: "!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
numberOfWords: 4,
|
||||
capitalize: DefaultPassphraseGenerationOptions.capitalize,
|
||||
number: true,
|
||||
separator: "!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should default includeNumber", async () => {
|
||||
const result = optionsToEffWordListRequest({
|
||||
numWords: 4,
|
||||
capitalize: true,
|
||||
wordSeparator: "!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
numberOfWords: 4,
|
||||
capitalize: true,
|
||||
number: DefaultPassphraseGenerationOptions.includeNumber,
|
||||
separator: "!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should default wordSeparator", async () => {
|
||||
const result = optionsToEffWordListRequest({
|
||||
numWords: 4,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
numberOfWords: 4,
|
||||
capitalize: true,
|
||||
number: true,
|
||||
separator: DefaultPassphraseGenerationOptions.wordSeparator,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,13 @@ import {
|
|||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
DefaultPassphraseBoundaries,
|
||||
DefaultPassphraseGenerationOptions,
|
||||
DefaultPasswordGenerationOptions,
|
||||
} from "./data";
|
||||
import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types";
|
||||
|
||||
/** construct a method that outputs a copy of `defaultValue` as an observable. */
|
||||
export function observe$PerUserId<Value>(
|
||||
create: () => Partial<Value>,
|
||||
|
@ -50,3 +57,79 @@ export function sharedStateByUserId<Value>(key: UserKeyDefinition<Value>, provid
|
|||
/** returns the sum of items in the list. */
|
||||
export const sum = (...items: number[]) =>
|
||||
(items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0);
|
||||
|
||||
/* converts password generation option sets, which are defined by
|
||||
* an "enabled" and "quantity" parameter, to the password engine's
|
||||
* parameters, which represent disabled options as `undefined`
|
||||
* properties.
|
||||
*/
|
||||
export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) {
|
||||
// helper for processing common option sets
|
||||
function process(
|
||||
// values read from the options
|
||||
enabled: boolean,
|
||||
quantity: number,
|
||||
// value used if an option is missing
|
||||
defaultEnabled: boolean,
|
||||
defaultQuantity: number,
|
||||
) {
|
||||
const isEnabled = enabled ?? defaultEnabled;
|
||||
const actualQuantity = quantity ?? defaultQuantity;
|
||||
const result = isEnabled ? actualQuantity : undefined;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const request = {
|
||||
uppercase: process(
|
||||
options.uppercase,
|
||||
options.minUppercase,
|
||||
DefaultPasswordGenerationOptions.uppercase,
|
||||
DefaultPasswordGenerationOptions.minUppercase,
|
||||
),
|
||||
lowercase: process(
|
||||
options.lowercase,
|
||||
options.minLowercase,
|
||||
DefaultPasswordGenerationOptions.lowercase,
|
||||
DefaultPasswordGenerationOptions.minLowercase,
|
||||
),
|
||||
digits: process(
|
||||
options.number,
|
||||
options.minNumber,
|
||||
DefaultPasswordGenerationOptions.number,
|
||||
DefaultPasswordGenerationOptions.minNumber,
|
||||
),
|
||||
special: process(
|
||||
options.special,
|
||||
options.minSpecial,
|
||||
DefaultPasswordGenerationOptions.special,
|
||||
DefaultPasswordGenerationOptions.minSpecial,
|
||||
),
|
||||
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
|
||||
all: 0,
|
||||
};
|
||||
|
||||
// engine represents character sets as "include only"; you assert how many all
|
||||
// characters there can be rather than a total length. This conversion has
|
||||
// the character classes win, so that the result is always consistent with policy
|
||||
// minimums.
|
||||
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
|
||||
const remaining = (options.length ?? 0) - required;
|
||||
request.all = Math.max(remaining, 0);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/* converts passphrase generation option sets to the eff word list request
|
||||
*/
|
||||
export function optionsToEffWordListRequest(options: PassphraseGenerationOptions) {
|
||||
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
|
||||
const request = {
|
||||
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
|
||||
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
|
||||
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
|
||||
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
"commander": "11.1.0",
|
||||
"core-js": "3.36.1",
|
||||
"form-data": "4.0.0",
|
||||
"https-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.5",
|
||||
"inquirer": "8.2.6",
|
||||
"jquery": "3.7.1",
|
||||
"jsdom": "24.1.3",
|
||||
|
@ -129,7 +129,7 @@
|
|||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"electron": "32.0.1",
|
||||
"electron": "32.0.2",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-log": "5.0.1",
|
||||
"electron-reload": "2.0.0-alpha.1",
|
||||
|
@ -157,7 +157,7 @@
|
|||
"jest-mock-extended": "3.0.7",
|
||||
"jest-preset-angular": "14.1.1",
|
||||
"lint-staged": "15.2.8",
|
||||
"mini-css-extract-plugin": "2.8.1",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"node-ipc": "9.2.1",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-loader": "8.1.1",
|
||||
|
@ -192,7 +192,7 @@
|
|||
},
|
||||
"apps/browser": {
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2024.9.0"
|
||||
"version": "2024.9.1"
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@bitwarden/cli",
|
||||
|
@ -207,7 +207,7 @@
|
|||
"chalk": "4.1.2",
|
||||
"commander": "11.1.0",
|
||||
"form-data": "4.0.0",
|
||||
"https-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.5",
|
||||
"inquirer": "8.2.6",
|
||||
"jsdom": "24.1.3",
|
||||
"jszip": "3.10.1",
|
||||
|
@ -16433,9 +16433,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "32.0.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-32.0.1.tgz",
|
||||
"integrity": "sha512-5Hd5Jaf9niYVR2hZxoRd3gOrcxPOxQV1XPV5WaoSfT9jLJHFadhlKtuSDIk3U6rQZke+aC7GqPPAv55nWFCMsA==",
|
||||
"version": "32.0.2",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz",
|
||||
"integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
@ -21637,9 +21637,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
|
||||
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.0.2",
|
||||
|
@ -24733,19 +24733,6 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
|
||||
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.0.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
|
||||
|
@ -27721,9 +27708,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz",
|
||||
"integrity": "sha512-/1HDlyFRxWIZPI1ZpgqlZ8jMw/1Dp/dl3P0L1jtZ+zVcHqwPhGwaJwKL00WVgfnBy6PWCde9W65or7IIETImuA==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.1.tgz",
|
||||
"integrity": "sha512-+Vyi+GCCOHnrJ2VPS+6aPoXN2k2jgUzDRhTFLjjTBn23qyXJXkjUWQgTL+mXpF5/A8ixLdCc6kWsoeOjKGejKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"electron": "32.0.1",
|
||||
"electron": "32.0.2",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-log": "5.0.1",
|
||||
"electron-reload": "2.0.0-alpha.1",
|
||||
|
@ -119,7 +119,7 @@
|
|||
"jest-mock-extended": "3.0.7",
|
||||
"jest-preset-angular": "14.1.1",
|
||||
"lint-staged": "15.2.8",
|
||||
"mini-css-extract-plugin": "2.8.1",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"node-ipc": "9.2.1",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-loader": "8.1.1",
|
||||
|
@ -173,7 +173,7 @@
|
|||
"commander": "11.1.0",
|
||||
"core-js": "3.36.1",
|
||||
"form-data": "4.0.0",
|
||||
"https-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.5",
|
||||
"inquirer": "8.2.6",
|
||||
"jquery": "3.7.1",
|
||||
"jsdom": "24.1.3",
|
||||
|
|
Loading…
Reference in New Issue