1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -1,8 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -12,7 +12,7 @@ import { KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management"
import { BitwardenCsvExportType, BitwardenPasswordProtectedFileFormat } from "../types";
export class BaseVaultExportService {
constructor(
protected pinService: PinServiceAbstraction,
protected keyGenerationService: KeyGenerationService,
protected encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
private kdfConfigService: KdfConfigService,
@@ -26,7 +26,8 @@ export class BaseVaultExportService {
const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig(userId);
const salt = Utils.fromBufferToB64(await this.cryptoFunctionService.randomBytes(16));
const key = await this.pinService.makePinKey(password, salt, kdfConfig);
const key = await this.keyGenerationService.deriveVaultExportKey(password, salt, kdfConfig);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), key);
const encText = await this.encryptService.encryptString(clearText, key);

View File

@@ -3,13 +3,13 @@ import * as JSZip from "jszip";
import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherId, emptyGuid, UserId } from "@bitwarden/common/types/guid";
@@ -169,7 +169,7 @@ describe("VaultExportService", () => {
let exportService: IndividualVaultExportService;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let cipherService: MockProxy<CipherService>;
let pinService: MockProxy<PinServiceAbstraction>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let folderService: MockProxy<FolderService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
@@ -184,7 +184,7 @@ describe("VaultExportService", () => {
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
cipherService = mock<CipherService>();
pinService = mock<PinServiceAbstraction>();
keyGenerationService = mock<KeyGenerationService>();
folderService = mock<FolderService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
@@ -220,7 +220,7 @@ describe("VaultExportService", () => {
exportService = new IndividualVaultExportService(
folderService,
cipherService,
pinService,
keyGenerationService,
keyService,
encryptService,
cryptoFunctionService,
@@ -525,6 +525,20 @@ describe("VaultExportService", () => {
const exportedData = actual as ExportedVaultAsString;
expectEqualFolders(UserFolders, exportedData.data);
});
it("does not export the key property in unencrypted exports", async () => {
// Create a cipher with a key property
const cipherWithKey = generateCipherView(false);
(cipherWithKey as any).key = "shouldBeDeleted";
cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]);
const actual = await exportService.getExport(userId, "json");
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
const parsed = JSON.parse(exportedData.data);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].key).toBeUndefined();
});
});
export class FolderResponse {

View File

@@ -5,9 +5,9 @@ import * as papa from "papaparse";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
@@ -42,7 +42,7 @@ export class IndividualVaultExportService
constructor(
private folderService: FolderService,
private cipherService: CipherService,
pinService: PinServiceAbstraction,
keyGenerationService: KeyGenerationService,
private keyService: KeyService,
encryptService: EncryptService,
cryptoFunctionService: CryptoFunctionService,
@@ -50,7 +50,7 @@ export class IndividualVaultExportService
private apiService: ApiService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
super(keyGenerationService, encryptService, cryptoFunctionService, kdfConfigService);
}
/** Creates an export of an individual vault (My Vault). Based on the provided format it will either be unencrypted, encrypted or password protected and in case zip is selected will include attachments
@@ -317,6 +317,7 @@ export class IndividualVaultExportService
const cipher = new CipherWithIdExport();
cipher.build(c);
cipher.collectionIds = null;
delete cipher.key;
jsonDoc.items.push(cipher);
});

View File

@@ -3,16 +3,16 @@
import * as papa from "papaparse";
import { filter, firstValueFrom, map } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionData,
Collection,
CollectionDetailsResponse,
CollectionView,
} from "@bitwarden/admin-console/common";
CollectionDetailsResponse,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@@ -46,7 +46,7 @@ export class OrganizationVaultExportService
constructor(
private cipherService: CipherService,
private vaultExportApiService: VaultExportApiService,
pinService: PinServiceAbstraction,
keyGenerationService: KeyGenerationService,
private keyService: KeyService,
encryptService: EncryptService,
cryptoFunctionService: CryptoFunctionService,
@@ -54,7 +54,7 @@ export class OrganizationVaultExportService
kdfConfigService: KdfConfigService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
super(keyGenerationService, encryptService, cryptoFunctionService, kdfConfigService);
}
/** Creates a password protected export of an organizational vault.
@@ -383,6 +383,7 @@ export class OrganizationVaultExportService
decCiphers.forEach((c) => {
const cipher = new CipherWithIdExport();
cipher.build(c);
delete cipher.key;
jsonDoc.items.push(cipher);
});
return JSON.stringify(jsonDoc, null, " ");

View File

@@ -5,42 +5,48 @@ import {
} from "@bitwarden/common/models/export";
// Base
export type BitwardenJsonExport = {
encrypted: boolean;
items: CipherWithIdExport[];
};
export type BitwardenJsonExport = BitwardenUnEncryptedJsonExport | BitwardenEncryptedJsonExport;
// Decrypted
export type BitwardenUnEncryptedJsonExport = BitwardenJsonExport & {
encrypted: false;
};
export type BitwardenUnEncryptedJsonExport =
| BitwardenUnEncryptedIndividualJsonExport
| BitwardenUnEncryptedOrgJsonExport;
export type BitwardenUnEncryptedIndividualJsonExport = BitwardenUnEncryptedJsonExport & {
export type BitwardenUnEncryptedIndividualJsonExport = {
encrypted: false;
items: CipherWithIdExport[];
folders: FolderWithIdExport[];
};
export type BitwardenUnEncryptedOrgJsonExport = BitwardenUnEncryptedJsonExport & {
export type BitwardenUnEncryptedOrgJsonExport = {
encrypted: false;
items: CipherWithIdExport[];
collections: CollectionWithIdExport[];
};
// Account-encrypted
export type BitwardenEncryptedJsonExport = BitwardenJsonExport & {
export type BitwardenEncryptedJsonExport =
| BitwardenEncryptedIndividualJsonExport
| BitwardenEncryptedOrgJsonExport;
export type BitwardenEncryptedIndividualJsonExport = {
encrypted: true;
encKeyValidation_DO_NOT_EDIT: string;
};
export type BitwardenEncryptedIndividualJsonExport = BitwardenEncryptedJsonExport & {
items: CipherWithIdExport[];
folders: FolderWithIdExport[];
};
export type BitwardenEncryptedOrgJsonExport = BitwardenEncryptedJsonExport & {
export type BitwardenEncryptedOrgJsonExport = {
encrypted: true;
encKeyValidation_DO_NOT_EDIT: string;
items: CipherWithIdExport[];
collections: CollectionWithIdExport[];
};
// Password-protected
export type BitwardenPasswordProtectedFileFormat = {
encrypted: boolean;
passwordProtected: boolean;
encrypted: true;
passwordProtected: true;
salt: string;
kdfIterations: number;
kdfMemory?: number;
@@ -49,3 +55,50 @@ export type BitwardenPasswordProtectedFileFormat = {
encKeyValidation_DO_NOT_EDIT: string;
data: string;
};
// Unencrypted type guards
export function isUnencrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedJsonExport {
return data != null && (data as { encrypted?: unknown }).encrypted !== true;
}
export function isIndividualUnEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedIndividualJsonExport {
return isUnencrypted(data) && (data as { folders?: unknown }).folders != null;
}
export function isOrgUnEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedOrgJsonExport {
return isUnencrypted(data) && (data as { collections?: unknown }).collections != null;
}
// Encrypted type guards
export function isEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedJsonExport {
return data != null && (data as { encrypted?: unknown }).encrypted === true;
}
export function isPasswordProtected(
data: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport | null | undefined,
): data is BitwardenPasswordProtectedFileFormat {
return (
data != null &&
(data as { encrypted?: unknown }).encrypted === true &&
(data as { passwordProtected?: unknown }).passwordProtected === true
);
}
export function isIndividualEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedIndividualJsonExport {
return isEncrypted(data) && (data as { folders?: unknown }).folders != null;
}
export function isOrgEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedOrgJsonExport {
return isEncrypted(data) && (data as { collections?: unknown }).collections != null;
}

View File

@@ -18,7 +18,6 @@ import {
BehaviorSubject,
combineLatest,
firstValueFrom,
from,
map,
merge,
Observable,
@@ -43,8 +42,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ClientType, EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -100,7 +97,6 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
})
export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
private _organizationId$ = new BehaviorSubject<OrganizationId | undefined>(undefined);
private createDefaultLocationFlagEnabled$: Observable<boolean>;
private _showExcludeMyItems = false;
/**
@@ -259,13 +255,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
protected organizationService: OrganizationService,
private accountService: AccountService,
private collectionService: CollectionService,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
@Optional() private router?: Router,
) {}
async ngOnInit() {
this.observeFeatureFlags();
this.observeFormState();
this.observePolicyStatus();
this.observeFormSelections();
@@ -286,12 +280,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.setupPolicyBasedFormState();
}
private observeFeatureFlags(): void {
this.createDefaultLocationFlagEnabled$ = from(
this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation),
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
}
private observeFormState(): void {
this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
this.formDisabled.emit(c === "DISABLED");
@@ -380,32 +368,24 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
/**
* Determine value of showExcludeMyItems. Returns true when:
* CreateDefaultLocation feature flag is on
* AND organizationDataOwnershipPolicy is enabled for the selected organization
* organizationDataOwnershipPolicy is enabled for the selected organization
* AND a valid OrganizationId is present (not exporting from individual vault)
*/
private observeMyItemsExclusionCriteria(): void {
combineLatest({
createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$,
organizationDataOwnershipPolicyEnabledForOrg:
this.organizationDataOwnershipPolicyEnabledForOrg$,
organizationId: this._organizationId$,
})
.pipe(takeUntil(this.destroy$))
.subscribe(
({
createDefaultLocationFlagEnabled,
organizationDataOwnershipPolicyEnabledForOrg,
organizationId,
}) => {
if (!createDefaultLocationFlagEnabled || !organizationId) {
this._showExcludeMyItems = false;
return;
}
.subscribe(({ organizationDataOwnershipPolicyEnabledForOrg, organizationId }) => {
if (!organizationId) {
this._showExcludeMyItems = false;
return;
}
this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg;
},
);
this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg;
});
}
// Setup validator adjustments based on format and encryption type changes
@@ -620,7 +600,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
title: "confirmVaultExport",
bodyText: confirmDescription,
confirmButtonOptions: {
text: "exportVault",
text: "continue",
type: "primary",
},
});

View File

@@ -8,15 +8,18 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { FormFieldModule } from "@bitwarden/components";
import {
CatchallGenerationOptions,
CredentialGeneratorService,
BuiltIn,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
/** Options group for catchall emails */
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -24,7 +27,7 @@ import {
@Component({
selector: "tools-catchall-settings",
templateUrl: "catchall-settings.component.html",
standalone: false,
imports: [ReactiveFormsModule, FormFieldModule, JslibModule, I18nPipe],
})
export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges {
/** Instantiates the component

View File

@@ -1,8 +1,11 @@
<bit-dialog #dialog background="alt">
<span bitDialogTitle>{{ "generatorHistory" | i18n }}</span>
<ng-container bitDialogContent>
<bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" />
<bit-credential-generator-history [account]="account$ | async" *ngIf="hasHistory$ | async" />
@if (hasHistory$ | async) {
<bit-credential-generator-history [account]="account$ | async" />
} @else {
<bit-empty-credential-history style="display: contents" />
}
</ng-container>
<ng-container bitDialogFooter>
<button

View File

@@ -1,22 +1,24 @@
<bit-item *ngFor="let credential of credentials$ | async">
<bit-item-content>
<bit-color-password class="tw-font-mono" [password]="credential.credential" />
<div slot="secondary">
{{ credential.generationDate | date: "medium" }}
</div>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
[appCopyClick]="credential.credential"
[valueLabel]="getGeneratedValueText(credential)"
[label]="getCopyText(credential)"
showToast
>
{{ getCopyText(credential) }}
</button>
</bit-item-action>
</ng-container>
</bit-item>
@for (credential of credentials$ | async; track credential) {
<bit-item>
<bit-item-content>
<bit-color-password class="tw-font-mono" [password]="credential.credential" />
<div slot="secondary">
{{ credential.generationDate | date: "medium" }}
</div>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
[appCopyClick]="credential.credential"
[valueLabel]="getGeneratedValueText(credential)"
[label]="getCopyText(credential)"
showToast
>
{{ getCopyText(credential) }}
</button>
</bit-item-action>
</ng-container>
</bit-item>
}

View File

@@ -23,7 +23,6 @@ import {
import { AlgorithmsByType, CredentialGeneratorService } from "@bitwarden/generator-core";
import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history";
import { GeneratorModule } from "./generator.module";
import { translate } from "./util";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -32,13 +31,12 @@ import { translate } from "./util";
selector: "bit-credential-generator-history",
templateUrl: "credential-generator-history.component.html",
imports: [
ColorPasswordModule,
CommonModule,
ColorPasswordModule,
IconButtonModule,
NoItemsModule,
JslibModule,
ItemModule,
GeneratorModule,
],
})
export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, OnDestroy {

View File

@@ -6,9 +6,11 @@
(selectedChange)="onRootChanged({ nav: $event })"
attr.aria-label="{{ 'type' | i18n }}"
>
<bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value">
{{ option.label }}
</bit-toggle>
@for (option of rootOptions$ | async; track option) {
<bit-toggle [value]="option.value">
{{ option.label }}
</bit-toggle>
}
</bit-toggle-group>
<nudge-generator-spotlight></nudge-generator-spotlight>
@@ -40,69 +42,80 @@
></button>
</div>
</bit-card>
<tools-password-settings
class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.password"
[account]="account$ | async"
(onUpdated)="generate('password settings')"
/>
<tools-passphrase-settings
class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.passphrase"
[account]="account$ | async"
(onUpdated)="generate('passphrase settings')"
/>
<bit-section *ngIf="(category$ | async) !== 'password'">
<bit-section-header>
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header>
<div class="tw-mb-4">
<bit-card>
<form [formGroup]="username" class="tw-container">
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<bit-select
[items]="usernameOptions$ | async"
formControlName="nav"
data-testid="username-type"
>
</bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async
}}</bit-hint>
</bit-form-field>
</form>
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select
[items]="forwarderOptions$ | async"
formControlName="nav"
data-testid="email-forwarding-service"
>
</bit-select>
</bit-form-field>
</form>
<tools-catchall-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.catchall"
[account]="account$ | async"
(onUpdated)="generate('catchall settings')"
/>
<tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)"
[account]="account$ | async"
[forwarder]="forwarderId$ | async"
/>
<tools-subaddress-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.plusAddress"
[account]="account$ | async"
(onUpdated)="generate('subaddress settings')"
/>
<tools-username-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.username"
[account]="account$ | async"
(onUpdated)="generate('username settings')"
/>
</bit-card>
</div>
</bit-section>
@let showAlgorithm = showAlgorithm$ | async;
@let account = account$ | async;
@switch (showAlgorithm?.id) {
@case (Algorithm.password) {
<tools-password-settings
class="tw-mt-6"
[account]="account"
(onUpdated)="generate('password settings')"
/>
}
@case (Algorithm.passphrase) {
<tools-passphrase-settings
class="tw-mt-6"
[account]="account"
(onUpdated)="generate('passphrase settings')"
/>
}
}
@if ((category$ | async) !== "password") {
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header>
<div class="tw-mb-4">
<bit-card>
<form [formGroup]="username" class="tw-container">
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<bit-select
[items]="usernameOptions$ | async"
formControlName="nav"
data-testid="username-type"
>
</bit-select>
@if (credentialTypeHint$ | async) {
<bit-hint>{{ credentialTypeHint$ | async }}</bit-hint>
}
</bit-form-field>
</form>
@if (showForwarder$ | async) {
<form [formGroup]="forwarder" class="tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select
[items]="forwarderOptions$ | async"
formControlName="nav"
data-testid="email-forwarding-service"
>
</bit-select>
</bit-form-field>
</form>
}
@if (showAlgorithm?.id === Algorithm.catchall) {
<tools-catchall-settings
[account]="account"
(onUpdated)="generate('catchall settings')"
/>
}
@if (forwarderId$ | async; as forwarderId) {
<tools-forwarder-settings [account]="account" [forwarder]="forwarderId" />
}
@if (showAlgorithm?.id === Algorithm.plusAddress) {
<tools-subaddress-settings
[account]="account"
(onUpdated)="generate('subaddress settings')"
/>
}
@if (showAlgorithm?.id === Algorithm.username) {
<tools-username-settings
[account]="account"
(onUpdated)="generate('username settings')"
/>
}
</bit-card>
</div>
</bit-section>
}

View File

@@ -1,4 +1,5 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { AsyncPipe } from "@angular/common";
import {
Component,
EventEmitter,
@@ -10,7 +11,7 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import {
BehaviorSubject,
catchError,
@@ -27,6 +28,7 @@ import {
withLatestFrom,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -37,7 +39,23 @@ import {
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components";
import {
ToastService,
Option,
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
CopyClickDirective,
SectionComponent,
SectionHeaderComponent,
ToggleGroupModule,
TypographyModule,
FormFieldModule,
SelectModule,
} from "@bitwarden/components";
import {
CredentialType,
CredentialGeneratorService,
@@ -55,7 +73,15 @@ import {
Type,
} from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history";
import { I18nPipe } from "@bitwarden/ui-common";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { ForwarderSettingsComponent } from "./forwarder-settings.component";
import { NudgeGeneratorSpotlightComponent } from "./nudge-generator-spotlight.component";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordSettingsComponent } from "./password-settings.component";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameSettingsComponent } from "./username-settings.component";
import { translate } from "./util";
// constants used to identify navigation selections that are not
@@ -69,7 +95,32 @@ const NONE_SELECTED = "none";
@Component({
selector: "tools-credential-generator",
templateUrl: "credential-generator.component.html",
standalone: false,
imports: [
ToggleGroupModule,
NudgeGeneratorSpotlightComponent,
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
CopyClickDirective,
PasswordSettingsComponent,
PassphraseSettingsComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
ReactiveFormsModule,
FormFieldModule,
SelectModule,
CatchallSettingsComponent,
ForwarderSettingsComponent,
SubaddressSettingsComponent,
UsernameSettingsComponent,
AsyncPipe,
JslibModule,
I18nPipe,
],
})
export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestroy {
private readonly destroyed = new Subject<void>();

View File

@@ -1,28 +1,34 @@
<form [formGroup]="settings" class="tw-container">
<bit-form-field *ngIf="displayDomain">
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
<input
bitInput
formControlName="domain"
type="text"
placeholder="example.com"
(change)="save('domain')"
/>
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field *ngIf="displayToken">
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput formControlName="token" type="password" (change)="save('password')" />
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
(change)="save('token')"
></button>
</bit-form-field>
<bit-form-field *ngIf="displayBaseUrl" disableMargin>
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
<input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" />
</bit-form-field>
@if (displayDomain) {
<bit-form-field>
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
<input
bitInput
formControlName="domain"
type="text"
placeholder="example.com"
(change)="save('domain')"
/>
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
</bit-form-field>
}
@if (displayToken) {
<bit-form-field>
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput formControlName="token" type="password" (change)="save('password')" />
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
(change)="save('token')"
></button>
</bit-form-field>
}
@if (displayBaseUrl) {
<bit-form-field disableMargin>
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
<input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" />
</bit-form-field>
}
</form>

View File

@@ -8,16 +8,24 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { map, ReplaySubject, skip, Subject, switchAll, takeUntil, withLatestFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { VendorId } from "@bitwarden/common/tools/extension";
import {
FormFieldModule,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
} from "@bitwarden/components";
import {
CredentialGeneratorService,
ForwarderOptions,
GeneratorMetadata,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
const Controls = Object.freeze({
domain: "domain",
@@ -31,7 +39,15 @@ const Controls = Object.freeze({
@Component({
selector: "tools-forwarder-settings",
templateUrl: "forwarder-settings.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
FormFieldModule,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
JslibModule,
I18nPipe,
],
})
export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component

View File

@@ -1,67 +1,13 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
CardComponent,
ColorPasswordModule,
CheckboxModule,
FormFieldModule,
IconButtonModule,
InputModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
ToggleGroupModule,
TypographyModule,
} from "@bitwarden/components";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { CredentialGeneratorComponent } from "./credential-generator.component";
import { ForwarderSettingsComponent } from "./forwarder-settings.component";
import { GeneratorServicesModule } from "./generator-services.module";
import { NudgeGeneratorSpotlightComponent } from "./nudge-generator-spotlight.component";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordGeneratorComponent } from "./password-generator.component";
import { PasswordSettingsComponent } from "./password-settings.component";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameGeneratorComponent } from "./username-generator.component";
import { UsernameSettingsComponent } from "./username-settings.component";
/** Shared module containing generator component dependencies */
/** @deprecated Use individual components instead. */
@NgModule({
imports: [
CardComponent,
ColorPasswordModule,
CheckboxModule,
CommonModule,
FormFieldModule,
GeneratorServicesModule,
IconButtonModule,
InputModule,
ItemModule,
JslibModule,
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
ToggleGroupModule,
TypographyModule,
NudgeGeneratorSpotlightComponent,
],
declarations: [
CatchallSettingsComponent,
CredentialGeneratorComponent,
ForwarderSettingsComponent,
SubaddressSettingsComponent,
PasswordGeneratorComponent,
PassphraseSettingsComponent,
PasswordSettingsComponent,
UsernameGeneratorComponent,
UsernameSettingsComponent,
],
imports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
})
export class GeneratorModule {

View File

@@ -1,5 +1,15 @@
/**
* This file contains the public interface for the generator components library.
*
* Be mindful of what you export here, as those components should be considered stable
* and part of the public API contract.
*/
export { CredentialGeneratorComponent } from "./credential-generator.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { GeneratorModule } from "./generator.module";
export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module";
export { PasswordGeneratorComponent } from "./password-generator.component";
export { UsernameGeneratorComponent } from "./username-generator.component";

View File

@@ -1,16 +1,18 @@
<div class="tw-mb-4" *ngIf="showGeneratorSpotlight$ | async">
<bit-spotlight
[title]="'generatorNudgeTitle' | i18n"
(onDismiss)="dismissGeneratorSpotlight(NudgeType.GeneratorNudgeStatus)"
>
<p class="tw-text-main tw-mb-0" bitTypography="body2">
<span class="tw-sr-only">
{{ "generatorNudgeBodyAria" | i18n }}
</span>
<span aria-hidden="true">
{{ "generatorNudgeBodyOne" | i18n }} <i class="bwi bwi-generate"></i>
{{ "generatorNudgeBodyTwo" | i18n }}
</span>
</p>
</bit-spotlight>
</div>
@if (showGeneratorSpotlight$ | async) {
<div class="tw-mb-4">
<bit-spotlight
[title]="'generatorNudgeTitle' | i18n"
(onDismiss)="dismissGeneratorSpotlight(NudgeType.GeneratorNudgeStatus)"
>
<p class="tw-text-main tw-mb-0" bitTypography="body2">
<span class="tw-sr-only">
{{ "generatorNudgeBodyAria" | i18n }}
</span>
<span aria-hidden="true">
{{ "generatorNudgeBodyOne" | i18n }} <i class="bwi bwi-generate"></i>
{{ "generatorNudgeBodyTwo" | i18n }}
</span>
</p>
</bit-spotlight>
</div>
}

View File

@@ -1,7 +1,9 @@
<bit-section [disableMargin]="disableMargin">
<bit-section-header *ngIf="showHeader">
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
@if (showHeader) {
<bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
}
<form [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
@@ -51,7 +53,9 @@
/>
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
@if (policyInEffect) {
<p bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
}
</bit-card>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { AsyncPipe } from "@angular/common";
import {
OnInit,
Input,
@@ -9,9 +10,10 @@ import {
SimpleChanges,
OnChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -20,11 +22,21 @@ import {
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import {
SectionComponent,
SectionHeaderComponent,
BaseCardDirective,
CardComponent,
TypographyModule,
FormFieldModule,
CheckboxModule,
} from "@bitwarden/components";
import {
CredentialGeneratorService,
PassphraseGenerationOptions,
BuiltIn,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
const Controls = Object.freeze({
numWords: "numWords",
@@ -39,7 +51,19 @@ const Controls = Object.freeze({
@Component({
selector: "tools-passphrase-settings",
templateUrl: "passphrase-settings.component.html",
standalone: false,
imports: [
SectionComponent,
SectionHeaderComponent,
TypographyModule,
ReactiveFormsModule,
BaseCardDirective,
CardComponent,
FormFieldModule,
CheckboxModule,
AsyncPipe,
JslibModule,
I18nPipe,
],
})
export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component

View File

@@ -1,15 +1,18 @@
<bit-toggle-group
fullWidth
class="tw-mb-4"
[selected]="credentialType$ | async"
(selectedChange)="onCredentialTypeChanged($event)"
*ngIf="showCredentialTypes$ | async"
attr.aria-label="{{ 'type' | i18n }}"
>
<bit-toggle *ngFor="let option of passwordOptions$ | async" [value]="option.value">
{{ option.label }}
</bit-toggle>
</bit-toggle-group>
@if (showCredentialTypes$ | async) {
<bit-toggle-group
fullWidth
class="tw-mb-4"
[selected]="credentialType$ | async"
(selectedChange)="onCredentialTypeChanged($event)"
attr.aria-label="{{ 'type' | i18n }}"
>
@for (option of passwordOptions$ | async; track option) {
<bit-toggle [value]="option.value">
{{ option.label }}
</bit-toggle>
}
</bit-toggle-group>
}
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow tw-flex tw-items-center tw-min-w-0">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
@@ -37,17 +40,19 @@
></button>
</div>
</bit-card>
<tools-password-settings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === Algorithm.password"
[account]="account$ | async"
[disableMargin]="disableMargin"
(onUpdated)="generate('password settings')"
/>
<tools-passphrase-settings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === Algorithm.passphrase"
[account]="account$ | async"
(onUpdated)="generate('passphrase settings')"
[disableMargin]="disableMargin"
/>
@if ((algorithm$ | async)?.id === Algorithm.password) {
<tools-password-settings
class="tw-mt-6"
[account]="account$ | async"
[disableMargin]="disableMargin"
(onUpdated)="generate('password settings')"
/>
}
@if ((algorithm$ | async)?.id === Algorithm.passphrase) {
<tools-passphrase-settings
class="tw-mt-6"
[account]="account$ | async"
(onUpdated)="generate('passphrase settings')"
[disableMargin]="disableMargin"
/>
}

View File

@@ -1,5 +1,6 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { AsyncPipe } from "@angular/common";
import {
Component,
EventEmitter,
@@ -24,6 +25,7 @@ import {
withLatestFrom,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -33,7 +35,18 @@ import {
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components";
import {
ToastService,
Option,
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
CopyClickDirective,
ToggleGroupModule,
} from "@bitwarden/components";
import {
CredentialGeneratorService,
GeneratedCredential,
@@ -49,7 +62,10 @@ import {
Profile,
} from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history";
import { I18nPipe } from "@bitwarden/ui-common";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordSettingsComponent } from "./password-settings.component";
import { toAlgorithmInfo, translate } from "./util";
/** Options group for passwords */
@@ -58,7 +74,21 @@ import { toAlgorithmInfo, translate } from "./util";
@Component({
selector: "tools-password-generator",
templateUrl: "password-generator.component.html",
standalone: false,
imports: [
ToggleGroupModule,
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
CopyClickDirective,
PasswordSettingsComponent,
PassphraseSettingsComponent,
AsyncPipe,
JslibModule,
I18nPipe,
],
})
export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy {
constructor(

View File

@@ -1,7 +1,9 @@
<bit-section [disableMargin]="disableMargin">
<bit-section-header *ngIf="showHeader">
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header>
@if (showHeader) {
<bit-section-header>
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header>
}
<form [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
@@ -50,11 +52,7 @@
<input bitCheckbox type="checkbox" formControlName="number" (change)="save('number')" />
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
class="!tw-mb-0"
attr.aria-description="{{ 'specialCharactersDescription' | i18n }}"
title="{{ 'specialCharactersDescription' | i18n }}"
>
<bit-form-control class="!tw-mb-0" title="{{ 'specialCharactersDescription' | i18n }}">
<input
bitCheckbox
type="checkbox"
@@ -62,10 +60,15 @@
(change)="save('special')"
/>
<!-- hard-coded the special characters string because `$` indicates an i18n interpolation,
and is handled inconsistently across browsers. Angular template syntax is used to
ensure special characters are entity-encoded.
-->
<bit-label>{{ "!@#$%^&*" }}</bit-label>
and is handled inconsistently across browsers. Angular template syntax is used to
ensure special characters are entity-encoded.
-->
<bit-label>
<span aria-hidden="true">{{ "!@#$%^&*" }}</span>
<span class="tw-sr-only">
{{ "specialCharactersDescription" | i18n }}
</span>
</bit-label>
</bit-form-control>
</div>
<div class="tw-flex">
@@ -97,7 +100,9 @@
/>
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
@if (policyInEffect) {
<p bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
}
</bit-card>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { AsyncPipe } from "@angular/common";
import {
OnInit,
Input,
@@ -9,16 +10,27 @@ import {
SimpleChanges,
OnChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { takeUntil, Subject, map, filter, tap, skip, ReplaySubject, withLatestFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
SectionComponent,
SectionHeaderComponent,
BaseCardDirective,
CardComponent,
FormFieldModule,
TypographyModule,
CheckboxModule,
} from "@bitwarden/components";
import {
CredentialGeneratorService,
PasswordGenerationOptions,
BuiltIn,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
import { hasRangeOfValues } from "./util";
@@ -39,7 +51,19 @@ const Controls = Object.freeze({
@Component({
selector: "tools-password-settings",
templateUrl: "password-settings.component.html",
standalone: false,
imports: [
SectionComponent,
SectionHeaderComponent,
TypographyModule,
ReactiveFormsModule,
BaseCardDirective,
CardComponent,
FormFieldModule,
CheckboxModule,
AsyncPipe,
JslibModule,
I18nPipe,
],
})
export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component

View File

@@ -8,15 +8,18 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { FormFieldModule } from "@bitwarden/components";
import {
CredentialGeneratorService,
BuiltIn,
SubaddressGenerationOptions,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
/** Options group for plus-addressed emails */
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -24,7 +27,7 @@ import {
@Component({
selector: "tools-subaddress-settings",
templateUrl: "subaddress-settings.component.html",
standalone: false,
imports: [ReactiveFormsModule, FormFieldModule, JslibModule, I18nPipe],
})
export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component

View File

@@ -42,42 +42,41 @@
data-testid="username-type"
>
</bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async
}}</bit-hint>
@if (credentialTypeHint$ | async) {
<bit-hint>{{ credentialTypeHint$ | async }}</bit-hint>
}
</bit-form-field>
</form>
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select
[items]="forwarderOptions$ | async"
formControlName="nav"
data-testid="email-forwarding-service"
>
</bit-select>
</bit-form-field>
</form>
<tools-catchall-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.catchall"
[account]="account$ | async"
(onUpdated)="generate('catchall settings')"
/>
<tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async"
[account]="account$ | async"
/>
<tools-subaddress-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.plusAddress"
[account]="account$ | async"
(onUpdated)="generate('subaddress settings')"
/>
<tools-username-settings
*ngIf="(showAlgorithm$ | async)?.id === Algorithm.username"
[account]="account$ | async"
(onUpdated)="generate('username settings')"
/>
@if (showForwarder$ | async) {
<form [formGroup]="forwarder" class="tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select
[items]="forwarderOptions$ | async"
formControlName="nav"
data-testid="email-forwarding-service"
>
</bit-select>
</bit-form-field>
</form>
}
@let showAlgorithm = showAlgorithm$ | async;
@let account = account$ | async;
@if (showAlgorithm?.id === Algorithm.catchall) {
<tools-catchall-settings [account]="account" (onUpdated)="generate('catchall settings')" />
}
@if (forwarderId$ | async; as forwarderId) {
<tools-forwarder-settings [forwarder]="forwarderId" [account]="account" />
}
@if (showAlgorithm?.id === Algorithm.plusAddress) {
<tools-subaddress-settings
[account]="account"
(onUpdated)="generate('subaddress settings')"
/>
}
@if (showAlgorithm?.id === Algorithm.username) {
<tools-username-settings [account]="account" (onUpdated)="generate('username settings')" />
}
</bit-card>
</div>
</bit-section>

View File

@@ -1,5 +1,6 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass, AsyncPipe } from "@angular/common";
import {
Component,
EventEmitter,
@@ -11,7 +12,7 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import {
BehaviorSubject,
catchError,
@@ -28,6 +29,7 @@ import {
withLatestFrom,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -38,7 +40,22 @@ import {
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components";
import {
ToastService,
Option,
AriaDisableDirective,
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
CopyClickDirective,
BitIconButtonComponent,
TooltipDirective,
SectionComponent,
SectionHeaderComponent,
SelectComponent,
TypographyModule,
FormFieldModule,
} from "@bitwarden/components";
import {
AlgorithmInfo,
CredentialGeneratorService,
@@ -55,7 +72,12 @@ import {
Algorithm,
} from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history";
import { I18nPipe } from "@bitwarden/ui-common";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { ForwarderSettingsComponent } from "./forwarder-settings.component";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameSettingsComponent } from "./username-settings.component";
import { toAlgorithmInfo, translate } from "./util";
// constants used to identify navigation selections that are not
@@ -69,7 +91,29 @@ const NONE_SELECTED = "none";
@Component({
selector: "tools-username-generator",
templateUrl: "username-generator.component.html",
standalone: false,
imports: [
BaseCardDirective,
CardComponent,
ColorPasswordComponent,
AriaDisableDirective,
TooltipDirective,
BitIconButtonComponent,
CopyClickDirective,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
NgClass,
ReactiveFormsModule,
SelectComponent,
FormFieldModule,
CatchallSettingsComponent,
ForwarderSettingsComponent,
SubaddressSettingsComponent,
UsernameSettingsComponent,
AsyncPipe,
JslibModule,
I18nPipe,
],
})
export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the username generator

View File

@@ -8,15 +8,18 @@ import {
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { FormFieldModule, CheckboxModule } from "@bitwarden/components";
import {
CredentialGeneratorService,
EffUsernameGenerationOptions,
BuiltIn,
} from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
/** Options group for usernames */
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -24,7 +27,7 @@ import {
@Component({
selector: "tools-username-settings",
templateUrl: "username-settings.component.html",
standalone: false,
imports: [ReactiveFormsModule, FormFieldModule, CheckboxModule, JslibModule, I18nPipe],
})
export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component

View File

@@ -7,8 +7,7 @@ import { ForwarderContext } from "../forwarder-context";
export class CreateForwardingAddressRpc<
Settings extends ApiSettings,
Req extends IntegrationRequest = IntegrationRequest,
> implements JsonRpc<Req, string>
{
> implements JsonRpc<Req, string> {
constructor(
readonly requestor: ForwarderConfiguration<Settings>,
readonly context: ForwarderContext<Settings>,

View File

@@ -9,8 +9,7 @@ import { ForwarderContext } from "../forwarder-context";
export class GetAccountIdRpc<
Settings extends ApiSettings,
Req extends IntegrationRequest = IntegrationRequest,
> implements JsonRpc<Req, string>
{
> implements JsonRpc<Req, string> {
constructor(
readonly requestor: ForwarderConfiguration<Settings>,
readonly context: ForwarderContext<Settings>,

View File

@@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override,
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override,
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy, policy]);
@@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "password",
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const passphrase = new Policy({
id: "" as PolicyId,
@@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "passphrase",
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([password, passphrase]);
@@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: false,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: true,
revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);

View File

@@ -2,9 +2,10 @@ import { PolicyEvaluator } from "../abstractions";
import { NoPolicy } from "../types";
/** A policy evaluator that does not apply any policy */
export class DefaultPolicyEvaluator<PolicyTarget>
implements PolicyEvaluator<NoPolicy, PolicyTarget>
{
export class DefaultPolicyEvaluator<PolicyTarget> implements PolicyEvaluator<
NoPolicy,
PolicyTarget
> {
/** {@link PolicyEvaluator.policy} */
get policy() {
return {};

View File

@@ -13,9 +13,7 @@ import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
export class DynamicPasswordPolicyConstraints
implements DynamicStateConstraints<PasswordGeneratorSettings>
{
export class DynamicPasswordPolicyConstraints implements DynamicStateConstraints<PasswordGeneratorSettings> {
/** Instantiates the object.
* @param policy the password policy to enforce. This cannot be
* `null` or `undefined`.

View File

@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
revisionDate: new Date().toISOString(),
});
}

View File

@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
revisionDate: new Date().toISOString(),
});
}

View File

@@ -25,7 +25,11 @@ import { deepFreeze } from "@bitwarden/common/tools/util";
import { UserId } from "@bitwarden/common/types/guid";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec";
import {
FakeAccountService,
FakeStateProvider,
mockAccountInfoWith,
} from "../../../../../common/spec";
import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata";
import catchall from "../metadata/email/catchall";
import plusAddress from "../metadata/email/plus-address";
@@ -40,9 +44,10 @@ import { GeneratorMetadataProvider } from "./generator-metadata-provider";
const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
...mockAccountInfoWith({
email: "someone@example.com",
name: "Someone",
}),
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);

View File

@@ -15,7 +15,12 @@ import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/stat
import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
import {
FakeStateProvider,
FakeAccountService,
awaitAsync,
mockAccountInfoWith,
} from "../../../../../common/spec";
import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata";
import { GeneratorConstraints } from "../types";
@@ -31,21 +36,25 @@ const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId;
const accounts: Record<UserId, Account> = {
[SomeUser]: {
id: SomeUser,
name: "some user",
email: "some.user@example.com",
emailVerified: true,
...mockAccountInfoWith({
name: "some user",
email: "some.user@example.com",
}),
},
[AnotherUser]: {
id: AnotherUser,
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
...mockAccountInfoWith({
name: "some other user",
email: "some.other.user@example.com",
}),
},
[UnverifiedEmailUser]: {
id: UnverifiedEmailUser,
name: "a user with an unverfied email",
email: "unverified@example.com",
emailVerified: false,
...mockAccountInfoWith({
name: "a user with an unverfied email",
email: "unverified@example.com",
emailVerified: false,
}),
},
};
const accountService = new FakeAccountService(accounts);
@@ -57,6 +66,7 @@ const somePolicy = new Policy({
id: "" as PolicyId,
organizationId: "" as OrganizationId,
enabled: true,
revisionDate: new Date().toISOString(),
});
const stateProvider = new FakeStateProvider(accountService);

View File

@@ -8,7 +8,7 @@ import { Vendor } from "@bitwarden/common/tools/extension/vendor/data";
import { SemanticLogger, ifEnabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync } from "../../../../../common/spec";
import { awaitAsync, mockAccountInfoWith } from "../../../../../common/spec";
import {
Algorithm,
CredentialAlgorithm,
@@ -56,9 +56,10 @@ describe("DefaultCredentialGeneratorService", () => {
// Use a hard-coded value for mockAccount
account = {
id: "test-account-id" as UserId,
emailVerified: true,
email: "test@example.com",
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
system = {

View File

@@ -13,9 +13,10 @@ import { observe$PerUserId, sharedStateByUserId } from "../util";
import { CATCHALL_SETTINGS } from "./storage";
/** Strategy for creating usernames using a catchall email address */
export class CatchallGeneratorStrategy
implements GeneratorStrategy<CatchallGenerationOptions, NoPolicy>
{
export class CatchallGeneratorStrategy implements GeneratorStrategy<
CatchallGenerationOptions,
NoPolicy
> {
/** Instantiates the generation strategy
* @param usernameService generates a catchall address for a domain
*/

View File

@@ -16,9 +16,10 @@ const UsernameDigits = Object.freeze({
});
/** Strategy for creating usernames from the EFF wordlist */
export class EffUsernameGeneratorStrategy
implements GeneratorStrategy<EffUsernameGenerationOptions, NoPolicy>
{
export class EffUsernameGeneratorStrategy implements GeneratorStrategy<
EffUsernameGenerationOptions,
NoPolicy
> {
/** Instantiates the generation strategy
* @param usernameService generates a username from EFF word list
*/

View File

@@ -10,8 +10,7 @@ import { Classifier } from "@bitwarden/common/tools/state/classifier";
export class OptionsClassifier<
Settings,
Options extends IntegrationRequest & Settings = IntegrationRequest & Settings,
> implements Classifier<Options, Record<string, never>, Settings>
{
> implements Classifier<Options, Record<string, never>, Settings> {
/** Partitions `secret` into its disclosed properties and secret properties.
* @param value The object to partition
* @returns an object that classifies secrets.

View File

@@ -14,9 +14,10 @@ import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } f
import { PASSPHRASE_SETTINGS } from "./storage";
/** Generates passphrases composed of random words */
export class PassphraseGeneratorStrategy
implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy>
{
export class PassphraseGeneratorStrategy implements GeneratorStrategy<
PassphraseGenerationOptions,
PassphraseGeneratorPolicy
> {
/** instantiates the password generator strategy.
* @param legacy generates the passphrase
* @param stateProvider provides durable state

View File

@@ -12,9 +12,10 @@ import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } f
import { PASSWORD_SETTINGS } from "./storage";
/** Generates passwords composed of random characters */
export class PasswordGeneratorStrategy
implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy>
{
export class PasswordGeneratorStrategy implements GeneratorStrategy<
PasswordGenerationOptions,
PasswordGeneratorPolicy
> {
/** instantiates the password generator strategy.
* @param legacy generates the password
*/

View File

@@ -17,9 +17,10 @@ import { SUBADDRESS_SETTINGS } from "./storage";
* For example, if the email address is `jd+xyz@domain.io`,
* the subaddress is `xyz`.
*/
export class SubaddressGeneratorStrategy
implements GeneratorStrategy<SubaddressGenerationOptions, NoPolicy>
{
export class SubaddressGeneratorStrategy implements GeneratorStrategy<
SubaddressGenerationOptions,
NoPolicy
> {
/** Instantiates the generation strategy
* @param usernameService generates an email subaddress from an email address
*/

View File

@@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
enabled: true,
type: PolicyType.PasswordGenerator,
data: { overridePasswordType: "password" },
revisionDate: new Date().toISOString(),
}),
]);
},

View File

@@ -8,9 +8,10 @@ import { GeneratorNavigationPolicy } from "./generator-navigation-policy";
/** Enforces policy for generator navigation options.
*/
export class GeneratorNavigationEvaluator
implements PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>
{
export class GeneratorNavigationEvaluator implements PolicyEvaluator<
GeneratorNavigationPolicy,
GeneratorNavigation
> {
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.

View File

@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
revisionDate: new Date().toISOString(),
});
}

View File

@@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
DIALOG_DATA,
DialogRef,
@@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({
} as const);
/** A result of the Send add/edit dialog. */
export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
export type SendItemDialogResult = {
result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
send?: SendView;
};
/**
* Component for adding or editing a send item.
*/
@@ -93,7 +95,7 @@ export class SendAddEditDialogComponent {
*/
async onSendCreated(send: SendView) {
// FIXME Add dialogService.open send-created dialog
this.dialogRef.close(SendItemDialogResult.Saved);
this.dialogRef.close({ result: SendItemDialogResult.Saved, send });
return;
}
@@ -101,14 +103,14 @@ export class SendAddEditDialogComponent {
* Handles the event when the send is updated.
*/
async onSendUpdated(send: SendView) {
this.dialogRef.close(SendItemDialogResult.Saved);
this.dialogRef.close({ result: SendItemDialogResult.Saved });
}
/**
* Handles the event when the send is deleted.
*/
async onSendDeleted() {
this.dialogRef.close(SendItemDialogResult.Deleted);
this.dialogRef.close({ result: SendItemDialogResult.Deleted });
this.toastService.showToast({
variant: "success",
@@ -174,4 +176,19 @@ export class SendAddEditDialogComponent {
},
);
}
/**
* Opens the send add/edit dialog in a drawer
* @param dialogService Instance of the DialogService.
* @param params The parameters for the drawer.
* @returns The drawer result.
*/
static openDrawer(dialogService: DialogService, params: SendItemDialogParams) {
return dialogService.openDrawer<SendItemDialogResult, SendItemDialogParams>(
SendAddEditDialogComponent,
{
data: params,
},
);
}
}

View File

@@ -1,8 +1,11 @@
export * from "./send-form";
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
export { NewSendDropdownV2Component } from "./new-send-dropdown-v2/new-send-dropdown-v2.component";
export * from "./add-edit/send-add-edit-dialog.component";
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component";
export { SendItemsService } from "./services/send-items.service";
export { SendSearchComponent } from "./send-search/send-search.component";
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
export { SendListFiltersService } from "./services/send-list-filters.service";
export { SendTableComponent } from "./send-table/send-table.component";
export { SendListComponent, SendListState } from "./send-list/send-list.component";

View File

@@ -0,0 +1,19 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType()" type="button">
@if (!hideIcon()) {
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
}
{{ (hideIcon() ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>
<button bitMenuItem type="button" (click)="onTextSendClick()">
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
{{ "sendTypeText" | i18n }}
</button>
<button bitMenuItem type="button" (click)="onFileSendClick()">
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
{{ "sendTypeFile" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
</button>
</bit-menu>

View File

@@ -0,0 +1,261 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component";
describe("NewSendDropdownV2Component", () => {
let component: NewSendDropdownV2Component;
let fixture: ComponentFixture<NewSendDropdownV2Component>;
let billingService: MockProxy<BillingAccountProfileStateService>;
let accountService: MockProxy<AccountService>;
let premiumUpgradeService: MockProxy<PremiumUpgradePromptService>;
beforeEach(async () => {
billingService = mock<BillingAccountProfileStateService>();
accountService = mock<AccountService>();
premiumUpgradeService = mock<PremiumUpgradePromptService>();
// Default: user has premium
accountService.activeAccount$ = of({ id: "user-123" } as any);
billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
const i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
await TestBed.configureTestingModule({
imports: [NewSendDropdownV2Component],
providers: [
{ provide: BillingAccountProfileStateService, useValue: billingService },
{ provide: AccountService, useValue: accountService },
{ provide: PremiumUpgradePromptService, useValue: premiumUpgradeService },
{ provide: I18nService, useValue: i18nService },
],
}).compileComponents();
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
jest.clearAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("input signals", () => {
it("has correct default input values", () => {
expect(component.hideIcon()).toBe(false);
expect(component.buttonType()).toBe("primary");
});
it("accepts input signal values", () => {
fixture.componentRef.setInput("hideIcon", true);
fixture.componentRef.setInput("buttonType", "secondary");
expect(component.hideIcon()).toBe(true);
expect(component.buttonType()).toBe("secondary");
});
});
describe("premium status detection", () => {
it("hasNoPremium is false when user has premium", () => {
billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
accountService.activeAccount$ = of({ id: "user-123" } as any);
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component["hasNoPremium"]()).toBe(false);
});
it("hasNoPremium is true when user lacks premium", () => {
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
accountService.activeAccount$ = of({ id: "user-123" } as any);
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component["hasNoPremium"]()).toBe(true);
});
it("hasNoPremium defaults to true when no active account", () => {
accountService.activeAccount$ = of(null);
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component["hasNoPremium"]()).toBe(true);
});
it("hasNoPremium updates reactively when premium status changes", async () => {
const premiumSubject = new BehaviorSubject(false);
billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component["hasNoPremium"]()).toBe(true);
premiumSubject.next(true);
await fixture.whenStable();
fixture.detectChanges();
expect(component["hasNoPremium"]()).toBe(false);
});
});
describe("text send functionality", () => {
it("onTextSendClick emits SendType.Text", () => {
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
component["onTextSendClick"]();
expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
});
it("allows text send without premium", () => {
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
component["onTextSendClick"]();
expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
});
});
describe("file send premium gating", () => {
it("onFileSendClick emits SendType.File when user has premium", async () => {
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
await component["onFileSendClick"]();
expect(emitSpy).toHaveBeenCalledWith(SendType.File);
expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
});
it("onFileSendClick shows premium prompt without premium", async () => {
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
premiumUpgradeService.promptForPremium.mockResolvedValue();
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
await component["onFileSendClick"]();
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalled();
});
it("does not emit file send type when premium prompt is shown", async () => {
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
await component["onFileSendClick"]();
expect(emitSpy).not.toHaveBeenCalledWith(SendType.File);
});
it("allows file send after user gains premium", async () => {
const premiumSubject = new BehaviorSubject(false);
billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
// Initially no premium
let emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
await component["onFileSendClick"]();
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
// Gain premium
premiumSubject.next(true);
await fixture.whenStable();
fixture.detectChanges();
// Now should emit
emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
await component["onFileSendClick"]();
expect(emitSpy).toHaveBeenCalledWith(SendType.File);
});
});
describe("edge cases", () => {
it("handles null account without errors", () => {
accountService.activeAccount$ = of(null);
expect(() => {
fixture = TestBed.createComponent(NewSendDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
}).not.toThrow();
expect(component["hasNoPremium"]()).toBe(true);
});
it("handles rapid clicks without race conditions", async () => {
const emitSpy = jest.fn();
component.addSend.subscribe(emitSpy);
// Rapid text send clicks
component["onTextSendClick"]();
component["onTextSendClick"]();
component["onTextSendClick"]();
expect(emitSpy).toHaveBeenCalledTimes(3);
// Rapid file send clicks (with premium)
await Promise.all([
component["onFileSendClick"](),
component["onFileSendClick"](),
component["onFileSendClick"](),
]);
expect(emitSpy).toHaveBeenCalledTimes(6); // 3 text + 3 file
});
it("cleans up subscriptions on destroy", () => {
const subscription = component["hasNoPremium"];
fixture.destroy();
// Signal should still exist but component cleanup handled by Angular
expect(() => subscription()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, inject, input, output } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map, of, switchMap } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";
// Desktop-specific version of NewSendDropdownComponent.
// Unlike the shared library version, this component emits events instead of using Angular Router,
// which aligns with Desktop's modal-based architecture.
@Component({
selector: "tools-new-send-dropdown-v2",
templateUrl: "new-send-dropdown-v2.component.html",
imports: [JslibModule, ButtonModule, MenuModule, PremiumBadgeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NewSendDropdownV2Component {
readonly hideIcon = input<boolean>(false);
readonly buttonType = input<ButtonType>("primary");
readonly addSend = output<SendType>();
protected sendType = SendType;
private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService);
private readonly accountService = inject(AccountService);
private readonly premiumUpgradePromptService = inject(PremiumUpgradePromptService);
protected readonly hasNoPremium = toSignal(
this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (!account) {
return of(true);
}
return this.billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => !hasPremium));
}),
),
{ initialValue: true },
);
protected onTextSendClick(): void {
this.addSend.emit(SendType.Text);
}
protected async onFileSendClick(): Promise<void> {
if (this.hasNoPremium()) {
await this.premiumUpgradePromptService.promptForPremium();
} else {
this.addSend.emit(SendType.File);
}
}
}

View File

@@ -1,5 +1,5 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus" aria-hidden="true"></i>
<i *ngIf="!hideIcon" class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>

View File

@@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";

View File

@@ -1,5 +1,5 @@
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
/**

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DialogService, ToastService } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { SendFormContainer } from "../../send-form-container";
import { SendOptionsComponent } from "./send-options.component";
describe("SendOptionsComponent", () => {
let component: SendOptionsComponent;
let fixture: ComponentFixture<SendOptionsComponent>;
const mockSendFormContainer = mock<SendFormContainer>();
const mockAccountService = mock<AccountService>();
beforeAll(() => {
mockAccountService.activeAccount$ = of({ id: "myTestAccount" } as Account);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SendOptionsComponent],
declarations: [],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: CredentialGeneratorService, useValue: mock<CredentialGeneratorService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendOptionsComponent);
component = fixture.componentInstance;
component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text };
fixture.detectChanges();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit a null password when password textbox is empty", async () => {
const newSend = {} as SendView;
mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend));
component.sendOptionsForm.patchValue({ password: "testing" });
expect(newSend.password).toBe("testing");
component.sendOptionsForm.patchValue({ password: "" });
expect(newSend.password).toBe(null);
});
});

View File

@@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -12,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -112,18 +113,27 @@ export class SendOptionsComponent implements OnInit {
this.disableHideEmail = disableHideEmail;
});
this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
this.sendOptionsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
});
return send;
});
});
}
generatePassword = async () => {

View File

@@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
SectionComponent,
SectionHeaderComponent,

View File

@@ -1,7 +1,9 @@
<bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config().mode === 'edit'">
<div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div>
<div data-testid="file-name">{{ originalSendView().file.fileName }}</div>
<div class="tw-text-wrap tw-break-all" data-testid="file-name">
{{ originalSendView().file.fileName }}
</div>
<div data-testid="file-size" class="tw-text-muted">{{ originalSendView().file.sizeName }}</div>
</div>
<bit-form-field *ngIf="config().mode !== 'edit'">

View File

@@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
ButtonModule,
FormFieldModule,

View File

@@ -18,9 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
AsyncActionsModule,
BitSubmitDirective,
@@ -227,10 +226,6 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
return;
}
if (Utils.isNullOrWhitespace(this.updatedSendView.password)) {
this.updatedSendView.password = null;
}
this.toastService.showToast({
variant: "success",
title: null,

View File

@@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
import {

View File

@@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ChipSelectComponent } from "@bitwarden/components";
@@ -31,9 +32,11 @@ describe("SendListFiltersComponent", () => {
accountService.activeAccount$ = of({
id: userId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
emailVerified: true,
}),
});
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));

View File

@@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
BadgeModule,
ButtonModule,

View File

@@ -0,0 +1,31 @@
@if (loading()) {
<bit-spinner />
} @else {
@if (showSearchBar()) {
<!-- Search Bar - hidden when no Sends exist -->
<tools-send-search></tools-send-search>
}
<tools-send-table
[dataSource]="dataSource"
[disableSend]="disableSend()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(removePassword)="onRemovePassword($event)"
(deleteSend)="onDeleteSend($event)"
/>
@if (noSearchResults()) {
<!-- No Sends from Search results -->
<bit-no-items [icon]="noItemIcon">
<ng-container slot="title">{{ "sendsTitleNoSearchResults" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsBodyNoSearchResults" | i18n }}</ng-container>
</bit-no-items>
} @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) {
<!-- No Sends from Filter results ( File/Text ) -->
<!-- No Sends exist at all -->
<bit-no-items [icon]="noItemIcon">
<ng-container slot="title">{{ "sendsTitleNoItems" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsBodyNoItems" | i18n }}</ng-container>
<ng-content select="[slot='empty-button']" slot="button" />
</bit-no-items>
}
}

View File

@@ -0,0 +1,89 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendItemsService } from "../services/send-items.service";
import { SendListComponent } from "./send-list.component";
describe("SendListComponent", () => {
let component: SendListComponent;
let fixture: ComponentFixture<SendListComponent>;
let i18nService: MockProxy<I18nService>;
let sendItemsService: MockProxy<SendItemsService>;
beforeEach(async () => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key) => key);
// Mock SendItemsService for SendSearchComponent child component
sendItemsService = mock<SendItemsService>();
sendItemsService.latestSearchText$ = of("");
await TestBed.configureTestingModule({
imports: [SendListComponent],
providers: [
{ provide: I18nService, useValue: i18nService },
{ provide: SendItemsService, useValue: sendItemsService },
],
}).compileComponents();
fixture = TestBed.createComponent(SendListComponent);
component = fixture.componentInstance;
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should display empty state when listState is Empty", () => {
fixture.componentRef.setInput("sends", []);
fixture.componentRef.setInput("listState", "Empty");
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain("sendsTitleNoItems");
});
it("should display no results state when listState is NoResults", () => {
fixture.componentRef.setInput("sends", []);
fixture.componentRef.setInput("listState", "NoResults");
fixture.detectChanges();
const compiled = fixture.nativeElement;
// Component shows same empty state for both Empty and NoResults states
expect(compiled.textContent).toContain("sendsTitleNoItems");
});
it("should emit editSend event when send is edited", () => {
const editSpy = jest.fn();
component.editSend.subscribe(editSpy);
const mockSend = { id: "test-id", name: "Test Send" } as any;
component["onEditSend"](mockSend);
expect(editSpy).toHaveBeenCalledWith(mockSend);
});
it("should emit copySend event when send link is copied", () => {
const copySpy = jest.fn();
component.copySend.subscribe(copySpy);
const mockSend = { id: "test-id", name: "Test Send" } as any;
component["onCopySend"](mockSend);
expect(copySpy).toHaveBeenCalledWith(mockSend);
});
it("should emit deleteSend event when send is deleted", () => {
const deleteSpy = jest.fn();
component.deleteSend.subscribe(deleteSpy);
const mockSend = { id: "test-id", name: "Test Send" } as any;
component["onDeleteSend"](mockSend);
expect(deleteSpy).toHaveBeenCalledWith(mockSend);
});
});

View File

@@ -0,0 +1,94 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
ButtonModule,
NoItemsModule,
SpinnerComponent,
TableDataSource,
} from "@bitwarden/components";
import { SendSearchComponent } from "../send-search/send-search.component";
import { SendTableComponent } from "../send-table/send-table.component";
/** A state of the Send list UI. */
export const SendListState = Object.freeze({
/** No Sends exist at all (File or Text). */
Empty: "Empty",
/** Sends exist, but none match the current Side Nav Filter (File or Text). */
NoResults: "NoResults",
} as const);
/** A state of the Send list UI. */
export type SendListState = (typeof SendListState)[keyof typeof SendListState];
/**
* A container component for displaying the Send list with search, table, and empty states.
* Handles the presentation layer while delegating data management to services.
*/
@Component({
selector: "tools-send-list",
templateUrl: "./send-list.component.html",
imports: [
CommonModule,
JslibModule,
ButtonModule,
NoItemsModule,
SpinnerComponent,
SendSearchComponent,
SendTableComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendListComponent {
protected readonly noItemIcon = NoSendsIcon;
protected readonly noResultsIcon = NoResults;
protected readonly sendListState = SendListState;
readonly sends = input.required<SendView[]>();
readonly loading = input<boolean>(false);
readonly disableSend = input<boolean>(false);
readonly listState = input<SendListState | null>(null);
readonly searchText = input<string>("");
protected readonly showSearchBar = computed(
() => this.sends().length > 0 || this.searchText().length > 0,
);
protected readonly noSearchResults = computed(
() => this.showSearchBar() && this.sends().length === 0,
);
// Reusable data source instance - updated reactively when sends change
protected readonly dataSource = new TableDataSource<SendView>();
constructor() {
effect(() => {
this.dataSource.data = this.sends();
});
}
readonly editSend = output<SendView>();
readonly copySend = output<SendView>();
readonly removePassword = output<SendView>();
readonly deleteSend = output<SendView>();
protected onEditSend(send: SendView): void {
this.editSend.emit(send);
}
protected onCopySend(send: SendView): void {
this.copySend.emit(send);
}
protected onRemovePassword(send: SendView): void {
this.removePassword.emit(send);
}
protected onDeleteSend(send: SendView): void {
this.deleteSend.emit(send);
}
}

View File

@@ -0,0 +1,114 @@
<div class="tw-@container/send-table">
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="deletionDate" class="@lg/send-table:tw-table-cell tw-hidden">
{{ "deletionDate" | i18n }}
</th>
<th bitCell>{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="onEditSend(s)" class="tw-cursor-pointer">
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
}
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
@if (s.disabled) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
}
@if (s.authType !== authType.None) {
@let titleKey =
s.authType === authType.Email ? "emailProtected" : "passwordProtected";
<i
class="bwi bwi-lock"
appStopProp
title="{{ titleKey | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ titleKey | i18n }}</span>
}
@if (s.maxAccessCountReached) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
}
@if (s.expired) {
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
}
@if (s.pendingDelete) {
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
}
</div>
</td>
<td
bitCell
(click)="onEditSend(s)"
class="tw-text-muted tw-cursor-pointer @lg/send-table:tw-table-cell tw-hidden"
>
<small bitTypography="body2" appStopProp>
{{ s.deletionDate | date: "medium" }}
</small>
</td>
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #sendOptions>
<button type="button" bitMenuItem (click)="onCopy(s)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySendLink" | i18n }}
</button>
@if (s.password && !disableSend()) {
<button type="button" bitMenuItem (click)="onRemovePassword(s)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "removePassword" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="onDelete(s)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</div>

View File

@@ -0,0 +1,111 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { TableDataSource, I18nMockService } from "@bitwarden/components";
import { SendTableComponent } from "./send-table.component";
function createMockSend(id: number, overrides: Partial<SendView> = {}): SendView {
const send = new SendView();
send.id = `send-${id}`;
send.name = "My Send";
send.type = SendType.Text;
send.authType = AuthType.None;
send.deletionDate = new Date("2030-01-01T12:00:00Z");
send.password = null as any;
Object.assign(send, overrides);
return send;
}
const dataSource = new TableDataSource<SendView>();
dataSource.data = [
createMockSend(0, {
name: "Project Documentation",
type: SendType.Text,
}),
createMockSend(1, {
name: "Meeting Notes",
type: SendType.File,
}),
createMockSend(2, {
name: "Password Protected Send",
type: SendType.Text,
authType: AuthType.Password,
password: "123",
}),
createMockSend(3, {
name: "Email Protected Send",
type: SendType.Text,
authType: AuthType.Email,
emails: ["ckent@dailyplanet.com"],
}),
createMockSend(4, {
name: "Disabled Send",
type: SendType.Text,
disabled: true,
}),
createMockSend(5, {
name: "Expired Send",
type: SendType.File,
expirationDate: new Date("2025-12-01T00:00:00Z"),
}),
createMockSend(6, {
name: "Max Access Reached",
type: SendType.Text,
authType: AuthType.Password,
maxAccessCount: 5,
accessCount: 5,
password: "123",
}),
];
export default {
title: "Tools/Sends/Send Table",
component: SendTableComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
name: "Name",
deletionDate: "Deletion Date",
options: "Options",
disabled: "Disabled",
passwordProtected: "Password protected",
emailProtected: "Email protected",
maxAccessCountReached: "Max access count reached",
expired: "Expired",
pendingDeletion: "Pending deletion",
copySendLink: "Copy Send link",
removePassword: "Remove password",
delete: "Delete",
loading: "Loading",
});
},
},
],
}),
],
args: {
dataSource,
disableSend: false,
},
argTypes: {
editSend: { action: "editSend" },
copySend: { action: "copySend" },
removePassword: { action: "removePassword" },
deleteSend: { action: "deleteSend" },
},
} as Meta<SendTableComponent>;
type Story = StoryObj<SendTableComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,94 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
BadgeModule,
ButtonModule,
IconButtonModule,
LinkModule,
MenuModule,
TableDataSource,
TableModule,
TypographyModule,
} from "@bitwarden/components";
/**
* A table component for displaying Send items with sorting, status indicators, and action menus. Handles the presentation of sends in a tabular format with options
* for editing, copying links, removing passwords, and deleting.
*/
@Component({
selector: "tools-send-table",
templateUrl: "./send-table.component.html",
imports: [
CommonModule,
JslibModule,
TableModule,
ButtonModule,
LinkModule,
IconButtonModule,
MenuModule,
BadgeModule,
TypographyModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendTableComponent {
protected readonly sendType = SendType;
protected readonly authType = AuthType;
/**
* The data source containing the Send items to display in the table.
*/
readonly dataSource = input<TableDataSource<SendView>>();
/**
* Whether Send functionality is disabled by policy.
* When true, the "Remove Password" option is hidden from the action menu.
*/
readonly disableSend = input(false);
/**
* Emitted when a user clicks on a Send item to edit it.
* The clicked SendView is passed as the event payload.
*/
readonly editSend = output<SendView>();
/**
* Emitted when a user clicks the "Copy Send Link" action.
* The SendView is passed as the event payload for generating and copying the link.
*/
readonly copySend = output<SendView>();
/**
* Emitted when a user clicks the "Remove Password" action.
* The SendView is passed as the event payload for password removal.
* This action is only available if the Send has a password and Send is not disabled.
*/
readonly removePassword = output<SendView>();
/**
* Emitted when a user clicks the "Delete" action.
* The SendView is passed as the event payload for deletion.
*/
readonly deleteSend = output<SendView>();
protected onEditSend(send: SendView): void {
this.editSend.emit(send);
}
protected onCopy(send: SendView): void {
this.copySend.emit(send);
}
protected onRemovePassword(send: SendView): void {
this.removePassword.emit(send);
}
protected onDelete(send: SendView): void {
this.deleteSend.emit(send);
}
}

View File

@@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendListFiltersService } from "./send-list-filters.service";

View File

@@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms";
import { map, Observable, startWith } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ChipSelectOption } from "@bitwarden/components";