mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
Merge branch 'main' into auth/pm-8111/browser-refresh-login-component
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
@@ -22,7 +23,7 @@ export default {
|
||||
component: AnonLayoutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule],
|
||||
imports: [ButtonModule, RouterModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
@@ -46,6 +47,10 @@ export default {
|
||||
}).asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { queryParams: of({}) },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -66,7 +71,7 @@ export const WithPrimaryContent: Story = {
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
@@ -83,7 +88,7 @@ export const WithSecondaryContent: Story = {
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
// Notice that slot="secondary" is requred to project any secondary content.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
@@ -104,7 +109,7 @@ export const WithLongContent: Story = {
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
|
||||
@@ -126,7 +131,7 @@ export const WithThinPrimaryContent: Story = {
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div class="text-center">Lorem ipsum</div>
|
||||
|
||||
<div slot="secondary" class="text-center">
|
||||
@@ -160,7 +165,7 @@ export const HideLogo: Story = {
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true">
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
@@ -176,7 +181,7 @@ export const HideFooter: Story = {
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true">
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ export enum FeatureFlag {
|
||||
EnableTimeThreshold = "PM-5864-dollar-threshold",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
@@ -64,7 +63,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableTimeThreshold]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { CollectionId, UserId } from "../../types/guid";
|
||||
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||
import { OrgKey } from "../../types/key";
|
||||
import { CollectionData } from "../models/data/collection.data";
|
||||
import { Collection } from "../models/domain/collection";
|
||||
import { TreeNode } from "../models/domain/tree-node";
|
||||
@@ -13,9 +14,13 @@ export abstract class CollectionService {
|
||||
encrypt: (model: CollectionView) => Promise<Collection>;
|
||||
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
|
||||
/**
|
||||
* @deprecated This method will soon be made private, use `decryptedCollectionViews$` instead.
|
||||
* @deprecated This method will soon be made private
|
||||
* See PM-12375
|
||||
*/
|
||||
decryptMany: (collections: Collection[]) => Promise<CollectionView[]>;
|
||||
decryptMany: (
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey>,
|
||||
) => Promise<CollectionView[]>;
|
||||
get: (id: string) => Promise<Collection>;
|
||||
getAll: () => Promise<Collection[]>;
|
||||
getAllDecrypted: () => Promise<CollectionView[]>;
|
||||
|
||||
135
libs/common/src/vault/services/collection.service.spec.ts
Normal file
135
libs/common/src/vault/services/collection.service.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeStateProvider,
|
||||
makeEncString,
|
||||
makeSymmetricCryptoKey,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../spec";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { ContainerService } from "../../platform/services/container.service";
|
||||
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||
import { OrgKey } from "../../types/key";
|
||||
import { CollectionData } from "../models/data/collection.data";
|
||||
|
||||
import { CollectionService, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.service";
|
||||
|
||||
describe("CollectionService", () => {
|
||||
afterEach(() => {
|
||||
delete (window as any).bitwardenContainerService;
|
||||
});
|
||||
|
||||
describe("decryptedCollections$", () => {
|
||||
it("emits decrypted collections from state", async () => {
|
||||
// Arrange test collections
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
const collection2 = collectionDataFactory(org2);
|
||||
|
||||
// Arrange state provider
|
||||
const fakeStateProvider = mockStateProvider();
|
||||
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, {
|
||||
[collection1.id]: collection1,
|
||||
[collection2.id]: collection2,
|
||||
});
|
||||
|
||||
// Arrange cryptoService - orgKeys and mock decryption
|
||||
const cryptoService = mockCryptoService();
|
||||
cryptoService.orgKeys$.mockReturnValue(
|
||||
of({
|
||||
[org1]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
[org2]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
}),
|
||||
);
|
||||
|
||||
const collectionService = new CollectionService(
|
||||
cryptoService,
|
||||
mock<EncryptService>(),
|
||||
mockI18nService(),
|
||||
fakeStateProvider,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(collectionService.decryptedCollections$);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: collection1.id,
|
||||
name: "DECRYPTED_STRING",
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: collection2.id,
|
||||
name: "DECRYPTED_STRING",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles null collection state", async () => {
|
||||
// Arrange test collections
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
|
||||
// Arrange state provider
|
||||
const fakeStateProvider = mockStateProvider();
|
||||
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null);
|
||||
|
||||
// Arrange cryptoService - orgKeys and mock decryption
|
||||
const cryptoService = mockCryptoService();
|
||||
cryptoService.orgKeys$.mockReturnValue(
|
||||
of({
|
||||
[org1]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
[org2]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
}),
|
||||
);
|
||||
|
||||
const collectionService = new CollectionService(
|
||||
cryptoService,
|
||||
mock<EncryptService>(),
|
||||
mockI18nService(),
|
||||
fakeStateProvider,
|
||||
);
|
||||
|
||||
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
|
||||
expect(decryptedCollections.length).toBe(0);
|
||||
|
||||
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
|
||||
expect(encryptedCollections.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockI18nService = () => {
|
||||
const i18nService = mock<I18nService>();
|
||||
i18nService.collator = null; // this is a mock only, avoid use of this object
|
||||
return i18nService;
|
||||
};
|
||||
|
||||
const mockStateProvider = () => {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
return new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
};
|
||||
|
||||
const mockCryptoService = () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
encryptService.decryptToUtf8
|
||||
.calledWith(expect.any(EncString), expect.anything())
|
||||
.mockResolvedValue("DECRYPTED_STRING");
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
|
||||
|
||||
return cryptoService;
|
||||
};
|
||||
|
||||
const collectionDataFactory = (orgId: OrganizationId) => {
|
||||
const collection = new CollectionData({} as any);
|
||||
collection.id = Utils.newGuid() as CollectionId;
|
||||
collection.organizationId = orgId;
|
||||
collection.name = makeEncString("ENC_STRING").encryptedString;
|
||||
|
||||
return collection;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||
import { OrgKey } from "../../types/key";
|
||||
import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service";
|
||||
import { CollectionData } from "../models/data/collection.data";
|
||||
import { Collection } from "../models/domain/collection";
|
||||
@@ -22,7 +23,7 @@ import { TreeNode } from "../models/domain/tree-node";
|
||||
import { CollectionView } from "../models/view/collection.view";
|
||||
import { ServiceUtils } from "../service-utils";
|
||||
|
||||
const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
|
||||
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
|
||||
COLLECTION_DATA,
|
||||
"collections",
|
||||
{
|
||||
@@ -31,19 +32,19 @@ const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, C
|
||||
},
|
||||
);
|
||||
|
||||
const DECRYPTED_COLLECTION_DATA_KEY = DeriveDefinition.from<
|
||||
Record<CollectionId, CollectionData>,
|
||||
const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
|
||||
[Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
|
||||
CollectionView[],
|
||||
{ collectionService: CollectionService }
|
||||
>(ENCRYPTED_COLLECTION_DATA_KEY, {
|
||||
>(COLLECTION_DATA, "decryptedCollections", {
|
||||
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
|
||||
derive: async (collections: Record<CollectionId, CollectionData>, { collectionService }) => {
|
||||
const data: Collection[] = [];
|
||||
for (const id in collections ?? {}) {
|
||||
const collectionId = id as CollectionId;
|
||||
data.push(new Collection(collections[collectionId]));
|
||||
derive: async ([collections, orgKeys], { collectionService }) => {
|
||||
if (collections == null) {
|
||||
return [];
|
||||
}
|
||||
return await collectionService.decryptMany(data);
|
||||
|
||||
const data = Object.values(collections).map((c) => new Collection(c));
|
||||
return await collectionService.decryptMany(data, orgKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,18 +69,25 @@ export class CollectionService implements CollectionServiceAbstraction {
|
||||
protected stateProvider: StateProvider,
|
||||
) {
|
||||
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
|
||||
|
||||
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
|
||||
map((collections) => {
|
||||
const response: Collection[] = [];
|
||||
for (const id in collections ?? {}) {
|
||||
response.push(new Collection(collections[id as CollectionId]));
|
||||
if (collections == null) {
|
||||
return [];
|
||||
}
|
||||
return response;
|
||||
|
||||
return Object.values(collections).map((c) => new Collection(c));
|
||||
}),
|
||||
);
|
||||
|
||||
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe(
|
||||
switchMap(([userId, collectionData]) =>
|
||||
combineLatest([of(collectionData), this.cryptoService.orgKeys$(userId)]),
|
||||
),
|
||||
);
|
||||
|
||||
this.decryptedCollectionDataState = this.stateProvider.getDerived(
|
||||
this.encryptedCollectionDataState.state$,
|
||||
encryptedCollectionsWithKeys,
|
||||
DECRYPTED_COLLECTION_DATA_KEY,
|
||||
{ collectionService: this },
|
||||
);
|
||||
@@ -108,19 +116,24 @@ export class CollectionService implements CollectionServiceAbstraction {
|
||||
return collection;
|
||||
}
|
||||
|
||||
async decryptMany(collections: Collection[]): Promise<CollectionView[]> {
|
||||
if (collections == null) {
|
||||
// TODO: this should be private and orgKeys should be required.
|
||||
// See https://bitwarden.atlassian.net/browse/PM-12375
|
||||
async decryptMany(
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey>,
|
||||
): Promise<CollectionView[]> {
|
||||
if (collections == null || collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const decCollections: CollectionView[] = [];
|
||||
|
||||
const organizationKeys = await firstValueFrom(this.cryptoService.activeUserOrgKeys$);
|
||||
orgKeys ??= await firstValueFrom(this.cryptoService.activeUserOrgKeys$);
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
collections.forEach((collection) => {
|
||||
promises.push(
|
||||
collection
|
||||
.decrypt(organizationKeys[collection.organizationId as OrganizationId])
|
||||
.decrypt(orgKeys[collection.organizationId as OrganizationId])
|
||||
.then((c) => decCollections.push(c)),
|
||||
);
|
||||
});
|
||||
|
||||
352
libs/tools/generator/core/src/rx.spec.ts
Normal file
352
libs/tools/generator/core/src/rx.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { EmptyError, Subject, tap } from "rxjs";
|
||||
|
||||
import { anyComplete, on, ready } from "./rx";
|
||||
|
||||
describe("anyComplete", () => {
|
||||
it("emits true when its input completes", () => {
|
||||
const input$ = new Subject<void>();
|
||||
|
||||
const emissions: boolean[] = [];
|
||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||
input$.complete();
|
||||
|
||||
expect(emissions).toEqual([true]);
|
||||
});
|
||||
|
||||
it("completes when its input is already complete", () => {
|
||||
const input = new Subject<void>();
|
||||
input.complete();
|
||||
|
||||
let completed = false;
|
||||
anyComplete(input).subscribe({ complete: () => (completed = true) });
|
||||
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("completes when any input completes", () => {
|
||||
const input$ = new Subject<void>();
|
||||
const completing$ = new Subject<void>();
|
||||
|
||||
let completed = false;
|
||||
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
|
||||
completing$.complete();
|
||||
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores emissions", () => {
|
||||
const input$ = new Subject<number>();
|
||||
|
||||
const emissions: boolean[] = [];
|
||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||
input$.next(1);
|
||||
input$.next(2);
|
||||
input$.complete();
|
||||
|
||||
expect(emissions).toEqual([true]);
|
||||
});
|
||||
|
||||
it("forwards errors", () => {
|
||||
const input$ = new Subject<void>();
|
||||
const expected = { some: "error" };
|
||||
|
||||
let error = null;
|
||||
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
|
||||
input$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ready", () => {
|
||||
it("connects when subscribed", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
let connected = false;
|
||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||
|
||||
// precondition: ready$ should be cold
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
expect(connected).toBe(false);
|
||||
|
||||
ready$.subscribe();
|
||||
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses source emissions until its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("suppresses source emissions until all watches emit", () => {
|
||||
const watchA$ = new Subject<void>();
|
||||
const watchB$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready([watchA$, watchB$]));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// preconditions: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
watchA$.next();
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watchB$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits the last source emission when its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([2]);
|
||||
});
|
||||
|
||||
it("emits all source emissions after its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
source$.next(2);
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("ignores repeated watch emissions", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
watch$.next();
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("completes when its source completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
let completed = false;
|
||||
ready$.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
source$.complete();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("errors when its source errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
source$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch completes before emitting", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.complete();
|
||||
|
||||
expect(error).toBeInstanceOf(EmptyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on", () => {
|
||||
it("connects when subscribed", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
let connected = false;
|
||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||
|
||||
// precondition: on$ should be cold
|
||||
const on$ = source$.pipe(on(watch$));
|
||||
expect(connected).toBeFalsy();
|
||||
|
||||
on$.subscribe();
|
||||
|
||||
expect(connected).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses source emissions until `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: on$ should be cold
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("repeats source emissions when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
source$.next(1);
|
||||
|
||||
watch$.next();
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("updates source emissions when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
source$.next(1);
|
||||
watch$.next();
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("emits a value when `on` emits before the source is ready", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("ignores repeated `on` emissions before the source is ready", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits only the latest source emission when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
source$.next(1);
|
||||
|
||||
watch$.next();
|
||||
|
||||
source$.next(2);
|
||||
source$.next(3);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("completes when its source completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
let complete: boolean = false;
|
||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||
|
||||
source$.complete();
|
||||
|
||||
expect(complete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("completes when its watch completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
let complete: boolean = false;
|
||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||
|
||||
watch$.complete();
|
||||
|
||||
expect(complete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("errors when its source errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
source$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,18 @@
|
||||
import { map, pipe } from "rxjs";
|
||||
import {
|
||||
concat,
|
||||
concatMap,
|
||||
connect,
|
||||
endWith,
|
||||
first,
|
||||
ignoreElements,
|
||||
map,
|
||||
Observable,
|
||||
pipe,
|
||||
race,
|
||||
ReplaySubject,
|
||||
takeUntil,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
|
||||
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
|
||||
|
||||
@@ -37,3 +51,86 @@ export function newDefaultEvaluator<Target>() {
|
||||
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an observable that, once subscribed, emits `true` then completes when
|
||||
* any input completes. If an input is already complete when the subscription
|
||||
* occurs, it emits immediately.
|
||||
* @param watch$ the observable(s) to watch for completion; if an array is passed,
|
||||
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
|
||||
* will never complete.
|
||||
* @returns An observable that emits `true` when any of its inputs
|
||||
* complete. The observable forwards the first error from its input.
|
||||
* @remarks This method is particularly useful in combination with `takeUntil` and
|
||||
* streams that are not guaranteed to complete on their own.
|
||||
*/
|
||||
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
|
||||
if (Array.isArray(watch$)) {
|
||||
const completes$ = watch$
|
||||
.filter((w$) => !!w$)
|
||||
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
|
||||
const completed$ = race(completes$);
|
||||
return completed$;
|
||||
} else {
|
||||
return watch$.pipe(ignoreElements(), endWith(true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observable that delays the input stream until all watches have
|
||||
* emitted a value. The watched values are not included in the source stream.
|
||||
* The last emission from the source is output when all the watches have
|
||||
* emitted at least once.
|
||||
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
|
||||
* `ready` will never emit.
|
||||
* @returns An observable that emits when the source stream emits. The observable
|
||||
* errors if one of its watches completes before emitting. It also errors if one
|
||||
* of its watches errors.
|
||||
*/
|
||||
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
|
||||
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
|
||||
return pipe(
|
||||
connect<T, Observable<T>>((source$) => {
|
||||
// this subscription is safe because `source$` connects only after there
|
||||
// is an external subscriber.
|
||||
const source = new ReplaySubject<T>(1);
|
||||
source$.subscribe(source);
|
||||
|
||||
// `concat` is subscribed immediately after it's returned, at which point
|
||||
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
||||
// after `source$` is hot, then the replay subject sends the last-captured
|
||||
// emission through immediately. Otherwise, `ready` waits for the next
|
||||
// emission
|
||||
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
|
||||
takeUntil(anyComplete(source)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observable that emits the latest value of the source stream
|
||||
* when `watch$` emits. If `watch$` emits before the stream emits, then
|
||||
* an emission occurs as soon as a value becomes ready.
|
||||
* @param watch$ the observable that triggers emissions
|
||||
* @returns An observable that emits when `watch$` emits. The observable
|
||||
* errors if its source stream errors. It also errors if `on` errors. It
|
||||
* completes if its watch completes.
|
||||
*
|
||||
* @remarks This works like `audit`, but it repeats emissions when
|
||||
* watch$ fires.
|
||||
*/
|
||||
export function on<T>(watch$: Observable<any>) {
|
||||
return pipe(
|
||||
connect<T, Observable<T>>((source$) => {
|
||||
const source = new ReplaySubject<T>(1);
|
||||
source$.subscribe(source);
|
||||
|
||||
return watch$
|
||||
.pipe(
|
||||
ready(source),
|
||||
concatMap(() => source.pipe(first())),
|
||||
)
|
||||
.pipe(takeUntil(anyComplete(source)));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<bit-section [formGroup]="sendTextDetailsForm" disableMargin>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sendTypeTextToShare" | i18n }}</bit-label>
|
||||
<textarea bitInput id="text" rows="6" formControlName="text"></textarea>
|
||||
<textarea bitInput id="text" rows="3" formControlName="text"></textarea>
|
||||
</bit-form-field>
|
||||
<bit-form-control>
|
||||
<input bitCheckbox type="checkbox" formControlName="hidden" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgIf } from "@angular/common";
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
@@ -101,6 +103,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
*/
|
||||
@Output() cipherSaved = new EventEmitter<CipherView>();
|
||||
|
||||
private formReadySubject = new Subject<void>();
|
||||
|
||||
@Output() formReady = this.formReadySubject.asObservable();
|
||||
|
||||
/**
|
||||
* The original cipher being edited or cloned. Null for add mode.
|
||||
*/
|
||||
@@ -173,9 +179,13 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
|
||||
async init() {
|
||||
this.loading = true;
|
||||
|
||||
// Force change detection so that all child components are destroyed and re-created
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
this.updatedCipherView = new CipherView();
|
||||
this.originalCipherView = null;
|
||||
this.cipherForm.reset();
|
||||
this.cipherForm = this.formBuilder.group<CipherForm>({});
|
||||
|
||||
if (this.config == null) {
|
||||
return;
|
||||
@@ -207,6 +217,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.formReadySubject.next();
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -214,6 +225,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
private addEditFormService: CipherFormService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle>
|
||||
{{ title }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<vault-cipher-form-generator
|
||||
[type]="params.type"
|
||||
(valueGenerated)="onValueGenerated($event)"
|
||||
></vault-cipher-form-generator>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="selectValue()"
|
||||
data-testid="select-button"
|
||||
>
|
||||
{{ selectButtonText }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -1,125 +0,0 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction";
|
||||
import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component";
|
||||
|
||||
import {
|
||||
WebVaultGeneratorDialogComponent,
|
||||
WebVaultGeneratorDialogParams,
|
||||
WebVaultGeneratorDialogAction,
|
||||
} from "./web-generator-dialog.component";
|
||||
|
||||
describe("WebVaultGeneratorDialogComponent", () => {
|
||||
let component: WebVaultGeneratorDialogComponent;
|
||||
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
|
||||
|
||||
let dialogRef: MockProxy<DialogRef<any>>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let passwordOptionsSubject: BehaviorSubject<any>;
|
||||
let usernameOptionsSubject: BehaviorSubject<any>;
|
||||
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let mockUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = mock<DialogRef<any>>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]);
|
||||
usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]);
|
||||
|
||||
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||
mockPasswordGenerationService.getOptions$.mockReturnValue(
|
||||
passwordOptionsSubject.asObservable(),
|
||||
);
|
||||
|
||||
mockUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
|
||||
mockUsernameGenerationService.getOptions$.mockReturnValue(
|
||||
usernameOptionsSubject.asObservable(),
|
||||
);
|
||||
|
||||
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: dialogRef,
|
||||
},
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: mockDialogData,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mockI18nService,
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: mock<PlatformUtilsService>(),
|
||||
},
|
||||
{
|
||||
provide: PasswordGenerationServiceAbstraction,
|
||||
useValue: mockPasswordGenerationService,
|
||||
},
|
||||
{
|
||||
provide: UsernameGenerationServiceAbstraction,
|
||||
useValue: mockUsernameGenerationService,
|
||||
},
|
||||
{
|
||||
provide: CipherFormGeneratorComponent,
|
||||
useValue: {
|
||||
passwordOptions$: passwordOptionsSubject.asObservable(),
|
||||
usernameOptions$: usernameOptionsSubject.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("initializes without errors", () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the dialog with 'canceled' result when close is called", () => {
|
||||
const closeSpy = jest.spyOn(dialogRef, "close");
|
||||
|
||||
(component as any).close();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledWith({
|
||||
action: WebVaultGeneratorDialogAction.Canceled,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the dialog with 'selected' result when selectValue is called", () => {
|
||||
const closeSpy = jest.spyOn(dialogRef, "close");
|
||||
const generatedValue = "generated-value";
|
||||
component.onValueGenerated(generatedValue);
|
||||
|
||||
(component as any).selectValue();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledWith({
|
||||
action: WebVaultGeneratorDialogAction.Selected,
|
||||
generatedValue: generatedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates generatedValue when onValueGenerated is called", () => {
|
||||
const generatedValue = "new-generated-value";
|
||||
component.onValueGenerated(generatedValue);
|
||||
|
||||
expect((component as any).generatedValue).toBe(generatedValue);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ButtonModule, DialogService } from "@bitwarden/components";
|
||||
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
|
||||
|
||||
import { DialogModule } from "../../../../../../libs/components/src/dialog";
|
||||
|
||||
export interface WebVaultGeneratorDialogParams {
|
||||
type: "password" | "username";
|
||||
}
|
||||
|
||||
export interface WebVaultGeneratorDialogResult {
|
||||
action: WebVaultGeneratorDialogAction;
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
export enum WebVaultGeneratorDialogAction {
|
||||
Selected = "selected",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "web-vault-generator-dialog",
|
||||
templateUrl: "./web-generator-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule],
|
||||
})
|
||||
export class WebVaultGeneratorDialogComponent {
|
||||
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator");
|
||||
protected selectButtonText = this.i18nService.t(
|
||||
this.isPassword ? "useThisPassword" : "useThisUsername",
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the dialog is generating a password/passphrase. If false, it is generating a username.
|
||||
* @protected
|
||||
*/
|
||||
protected get isPassword() {
|
||||
return this.params.type === "password";
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently generated value.
|
||||
* @protected
|
||||
*/
|
||||
protected generatedValue: string = "";
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams,
|
||||
private dialogRef: DialogRef<WebVaultGeneratorDialogResult>,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Close the dialog without selecting a value.
|
||||
*/
|
||||
protected close = () => {
|
||||
this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the dialog and select the currently generated value.
|
||||
*/
|
||||
protected selectValue = () => {
|
||||
this.dialogRef.close({
|
||||
action: WebVaultGeneratorDialogAction.Selected,
|
||||
generatedValue: this.generatedValue,
|
||||
});
|
||||
};
|
||||
|
||||
onValueGenerated(value: string) {
|
||||
this.generatedValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the vault generator dialog.
|
||||
*/
|
||||
static open(dialogService: DialogService, config: DialogConfig<WebVaultGeneratorDialogParams>) {
|
||||
return dialogService.open<WebVaultGeneratorDialogResult, WebVaultGeneratorDialogParams>(
|
||||
WebVaultGeneratorDialogComponent,
|
||||
{
|
||||
...config,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
|
||||
|
||||
import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service";
|
||||
|
||||
describe("WebCipherFormGenerationService", () => {
|
||||
let service: WebCipherFormGenerationService;
|
||||
let dialogService: jest.Mocked<DialogService>;
|
||||
let closed = of({});
|
||||
const close = jest.fn();
|
||||
const dialogRef = {
|
||||
close,
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
} as unknown as DialogRef<unknown, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WebCipherFormGenerationService,
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(WebCipherFormGenerationService);
|
||||
});
|
||||
|
||||
it("creates without error", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("generatePassword", () => {
|
||||
it("opens the password generator dialog and returns the generated value", async () => {
|
||||
const generatedValue = "generated-password";
|
||||
closed = of({ action: "generated", generatedValue });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generatePassword();
|
||||
|
||||
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
|
||||
data: { type: "password" },
|
||||
});
|
||||
expect(result).toBe(generatedValue);
|
||||
});
|
||||
|
||||
it("returns null if the dialog is canceled", async () => {
|
||||
closed = of({ action: "canceled" });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generatePassword();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUsername", () => {
|
||||
it("opens the username generator dialog and returns the generated value", async () => {
|
||||
const generatedValue = "generated-username";
|
||||
closed = of({ action: "generated", generatedValue });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generateUsername();
|
||||
|
||||
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
|
||||
data: { type: "username" },
|
||||
});
|
||||
expect(result).toBe(generatedValue);
|
||||
});
|
||||
|
||||
it("returns null if the dialog is canceled", async () => {
|
||||
closed = of({ action: "canceled" });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generateUsername();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { CipherFormGenerationService } from "@bitwarden/vault";
|
||||
|
||||
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
|
||||
|
||||
@Injectable()
|
||||
export class WebCipherFormGenerationService implements CipherFormGenerationService {
|
||||
private dialogService = inject(DialogService);
|
||||
|
||||
async generatePassword(): Promise<string> {
|
||||
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
|
||||
data: { type: "password" },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == null || result.action === "canceled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.generatedValue;
|
||||
}
|
||||
|
||||
async generateUsername(): Promise<string> {
|
||||
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
|
||||
data: { type: "username" },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == null || result.action === "canceled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.generatedValue;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
|
||||
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -44,7 +44,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
||||
AutofillOptionsViewComponent,
|
||||
],
|
||||
})
|
||||
export class CipherViewComponent implements OnInit, OnDestroy {
|
||||
export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
@Input({ required: true }) cipher: CipherView;
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,11 @@ export class CipherViewComponent implements OnInit, OnDestroy {
|
||||
private folderService: FolderService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
async ngOnChanges() {
|
||||
if (this.cipher == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadCipherData();
|
||||
|
||||
this.cardIsExpired = isCardExpired(this.cipher.card);
|
||||
|
||||
Reference in New Issue
Block a user