1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 19:04:01 +00:00

PM-29919 email verification on sends (#18260)

* PM-29919 email verification on sends

* PM-29919 resolved build issue

* PM-29919 refined who can view fields

* PM-29919 resolved lint issues

* PM-29919 resolved lint issues

* PM-29919 resolved unit tests

* PM-29919 resolved lint issues

* PM-29919 resolved unit test issue

* PM-29919 resolved pr comments

* PM-29919 resolved pr comments

* PM-29919 resolved unneeded label

* PM-29919 refactored to hide instead of disable

* PM-29919 resolved pr comments

* PM-29919 resolved no auth string in PM-31200

* PM-29919 resolved bugs
This commit is contained in:
bmbitwarden
2026-01-28 09:39:37 -05:00
committed by jaasen-livefront
parent 9085ae20d5
commit 8093b4fe81
8 changed files with 447 additions and 189 deletions

View File

@@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@@ -4610,11 +4619,11 @@
"message": "URI match detection is how Bitwarden identifies autofill suggestions.",
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
},
"regExAdvancedOptionWarning": {
"regExAdvancedOptionWarning": {
"message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy"
},
"startsWithAdvancedOptionWarning": {
"startsWithAdvancedOptionWarning": {
"message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy"
},
@@ -4622,7 +4631,7 @@
"message": "More about match detection",
"description": "Link to match detection docs on warning dialog for advance match strategy"
},
"uriAdvancedOption":{
"uriAdvancedOption": {
"message": "Advanced options",
"description": "Advanced option placeholder for uri option component"
},
@@ -4812,7 +4821,7 @@
}
}
},
"copyFieldCipherName": {
"copyFieldCipherName": {
"message": "Copy $FIELD$, $CIPHERNAME$",
"description": "Title for a button that copies a field value to the clipboard.",
"placeholders": {
@@ -4844,7 +4853,7 @@
"adminConsole": {
"message": "Admin Console"
},
"admin" :{
"admin": {
"message": "Admin"
},
"automaticUserConfirmation": {
@@ -4853,7 +4862,7 @@
"automaticUserConfirmationHint": {
"message": "Automatically confirm pending users while this device is unlocked"
},
"autoConfirmOnboardingCallout":{
"autoConfirmOnboardingCallout": {
"message": "Save time with automatic user confirmation"
},
"autoConfirmWarning": {
@@ -5793,7 +5802,7 @@
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"phishingPageTitleV2":{
"phishingPageTitleV2": {
"message": "Phishing attempt detected"
},
"phishingPageSummary": {
@@ -5813,7 +5822,7 @@
"message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this."
},
"phishingPageLearnMore" : {
"phishingPageLearnMore": {
"message": "Learn more about phishing detection"
},
"protectedBy": {
@@ -5981,7 +5990,7 @@
"cardNumberLabel": {
"message": "Card number"
},
"removeMasterPasswordForOrgUserKeyConnector":{
"removeMasterPasswordForOrgUserKeyConnector": {
"message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain."
},
"continueWithLogIn": {
@@ -5999,10 +6008,10 @@
"verifyYourOrganization": {
"message": "Verify your organization to log in"
},
"organizationVerified":{
"organizationVerified": {
"message": "Organization verified"
},
"domainVerified":{
"domainVerified": {
"message": "Domain verified"
},
"leaveOrganizationContent": {
@@ -6120,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@@ -586,6 +586,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@@ -1365,6 +1368,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@@ -12691,6 +12700,21 @@
"storageFullDescription": {
"message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage."
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"whenYouRemoveStorage": {
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
},

View File

@@ -7,64 +7,22 @@
<bit-label>{{ "limitSendViews" | i18n }}</bit-label>
<input bitInput type="number" formControlName="maxAccessCount" min="1" />
<bit-hint>{{ "limitSendViewsHint" | i18n }}</bit-hint>
<bit-hint *ngIf="shouldShowCount"
>&nbsp;{{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint
>
@if (shouldShowCount) {
<bit-hint>&nbsp;{{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint>
}
</bit-form-field>
<bit-form-field>
<bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" />
<button
data-testid="toggle-visibility-for-password"
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
*ngIf="!hasPassword"
></button>
<button
type="button"
bitIconButton="bwi-generate"
bitSuffix
[label]="'generatePassword' | i18n"
[disabled]="!config.areSendsAllowed"
(click)="generatePassword()"
data-testid="generate-password"
*ngIf="!hasPassword"
></button>
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[label]="'copyPassword' | i18n"
[disabled]="!config.areSendsAllowed || !sendOptionsForm.get('password').value"
[valueLabel]="'password' | i18n"
[appCopyClick]="sendOptionsForm.get('password').value"
showToast
*ngIf="!hasPassword"
></button>
<button
*ngIf="hasPassword"
class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]"
bitSuffix
type="button"
buttonType="danger"
bitIconButton="bwi-minus-circle"
[label]="'removePassword' | i18n"
[bitAction]="removePassword"
showToast
></button>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-control *ngIf="!disableHideEmail || originalSendView?.hideEmail">
<input
[disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value"
bitCheckbox
type="checkbox"
formControlName="hideEmail"
/>
<bit-label>{{ "hideYourEmail" | i18n }}</bit-label>
</bit-form-control>
@if (!disableHideEmail || originalSendView?.hideEmail) {
<bit-form-control>
<input
[disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value"
bitCheckbox
type="checkbox"
formControlName="hideEmail"
/>
<bit-label>{{ "hideYourEmail" | i18n }}</bit-label>
</bit-form-control>
}
<bit-form-field disableMargin>
<bit-label>{{ "privateNote" | i18n }}</bit-label>
<textarea bitInput rows="3" formControlName="notes"></textarea>

View File

@@ -5,12 +5,7 @@ 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";
@@ -32,14 +27,9 @@ describe("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);
@@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => {
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,32 +4,26 @@ 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, tap } from "rxjs";
import { switchMap, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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";
import {
TypographyModule,
AsyncActionsModule,
ButtonModule,
CardComponent,
CheckboxModule,
DialogService,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
TypographyModule,
SelectModule,
} from "@bitwarden/components";
import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container";
@@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container";
@Component({
selector: "tools-send-options",
templateUrl: "./send-options.component.html",
standalone: true,
imports: [
AsyncActionsModule,
ButtonModule,
@@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container";
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
],
})
@@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit {
@Input()
originalSendView: SendView;
disableHideEmail = false;
passwordRemoved = false;
sendOptionsForm = this.formBuilder.group({
maxAccessCount: [null as number],
accessCount: [null as number],
notes: [null as string],
password: [null as string],
hideEmail: [false as boolean],
});
get hasPassword(): boolean {
return this.originalSendView && this.originalSendView.password !== null;
}
get shouldShowCount(): boolean {
return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null;
}
@@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit {
constructor(
private sendFormContainer: SendFormContainer,
private dialogService: DialogService,
private sendApiService: SendApiService,
private formBuilder: FormBuilder,
private policyService: PolicyService,
private i18nService: I18nService,
private toastService: ToastService,
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {
this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm);
@@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit {
this.disableHideEmail = disableHideEmail;
});
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;
this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
});
});
}
generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send", type: Type.password });
const account$ = this.accountService.activeAccount$.pipe(
pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }),
);
const generatedCredential = await firstValueFrom(
this.generatorService.generate$({ on$, account$ }),
);
this.sendOptionsForm.patchValue({
password: generatedCredential.credential,
});
};
removePassword = async () => {
if (!this.originalSendView || !this.originalSendView.password) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removePassword" },
content: { key: "removePasswordConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
this.passwordRemoved = true;
await this.sendApiService.removePassword(this.originalSendView.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedPassword"),
});
this.originalSendView.password = null;
this.sendOptionsForm.patchValue({
password: null,
});
this.sendOptionsForm.get("password")?.enable();
};
ngOnInit() {
if (this.sendFormContainer.originalSendView) {
this.sendOptionsForm.patchValue({
maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount,
accessCount: this.sendFormContainer.originalSendView.accessCount,
password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder
hideEmail: this.sendFormContainer.originalSendView.hideEmail,
notes: this.sendFormContainer.originalSendView.notes,
});
}
if (this.hasPassword) {
this.sendOptionsForm.get("password")?.disable();
}
if (!this.config.areSendsAllowed) {
this.sendOptionsForm.disable();

View File

@@ -6,7 +6,7 @@
<bit-card>
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input appAutofocus bitInput type="text" formControlName="name" />
<input bitInput type="text" formControlName="name" />
</bit-form-field>
<tools-send-text-details
@@ -34,7 +34,7 @@
></button>
</bit-form-field>
<bit-form-field disableMargin>
<bit-form-field>
<bit-label>{{ "deletionDate" | i18n }}</bit-label>
<bit-select
id="deletionDate"
@@ -49,6 +49,80 @@
</bit-select>
<bit-hint>{{ "deletionDateDescV2" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field [disableMargin]="!sendDetailsForm.get('authType').value">
<bit-label>{{ "whoCanView" | i18n }}</bit-label>
<bit-select formControlName="authType">
@for (option of availableAuthTypes$ | async; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint>
}
</bit-form-field>
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
<bit-form-field disableMargin>
<bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" />
<div bitSuffix ngProjectAs="[bitSuffix]" class="tw-flex tw-items-center">
@if (!hasPassword) {
<button
data-testid="toggle-visibility-for-password"
type="button"
bitIconButton
size="small"
bitPasswordInputToggle
></button>
<button
type="button"
bitIconButton="bwi-generate"
size="small"
[label]="'generatePassword' | i18n"
[disabled]="!config.areSendsAllowed"
(click)="generatePassword()"
data-testid="generate-password"
></button>
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[label]="'copyPassword' | i18n"
[disabled]="!config.areSendsAllowed || !sendDetailsForm.get('password').value"
[valueLabel]="'password' | i18n"
[appCopyClick]="sendDetailsForm.get('password').value"
showToast
></button>
} @else {
<button
class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]"
type="button"
buttonType="danger"
bitIconButton="bwi-minus-circle"
size="small"
[label]="'removePassword' | i18n"
[bitAction]="removePassword"
showToast
></button>
}
</div>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
}
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-form-field disableMargin class="tw-mt-4">
<bit-label>{{ "emails" | i18n }}</bit-label>
<textarea
bitInput
formControlName="emails"
rows="3"
[placeholder]="'emailPlaceholder' | i18n"
></textarea>
<bit-hint>{{ "enterMultipleEmailsSeparatedByComma" | i18n }}</bit-hint>
</bit-form-field>
}
</bit-card>
<tools-send-options [config]="config" [originalSendView]="originalSendView"></tools-send-options>
</bit-section>

View File

@@ -1,4 +1,29 @@
import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component";
import { DatePipe } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
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 {
DatePreset,
SendDetailsComponent,
asDatePreset,
isDatePreset,
} from "./send-details.component";
describe("SendDetails DatePreset utilities", () => {
it("accepts all defined numeric presets", () => {
@@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => {
});
});
});
describe("SendDetailsComponent", () => {
let component: SendDetailsComponent;
let fixture: ComponentFixture<SendDetailsComponent>;
const mockSendFormContainer = mock<SendFormContainer>();
const mockI18nService = mock<I18nService>();
const mockConfigService = mock<ConfigService>();
const mockAccountService = mock<AccountService>();
const mockBillingStateService = mock<BillingAccountProfileStateService>();
const mockGeneratorService = mock<CredentialGeneratorService>();
const mockSendApiService = mock<SendApiService>();
const mockEnvironmentService = mock<EnvironmentService>();
beforeEach(async () => {
mockEnvironmentService.environment$ = of({
getSendUrl: () => "https://send.bitwarden.com/",
} as any);
mockAccountService.activeAccount$ = of({ id: "userId" } as Account);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockI18nService.t.mockImplementation((k) => k);
await TestBed.configureTestingModule({
imports: [SendDetailsComponent, ReactiveFormsModule],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: I18nService, useValue: mockI18nService },
{ provide: DatePipe, useValue: new DatePipe("en-US") },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: BillingAccountProfileStateService, useValue: mockBillingStateService },
{ provide: CredentialGeneratorService, useValue: mockGeneratorService },
{ provide: SendApiService, useValue: mockSendApiService },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendDetailsComponent);
component = fixture.componentInstance;
component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text };
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize authType to None if no password or emails", () => {
expect(component.sendDetailsForm.value.authType).toBe(AuthType.None);
});
it("should toggle validation based on authType", () => {
const emailsControl = component.sendDetailsForm.get("emails");
const passwordControl = component.sendDetailsForm.get("password");
// Default
expect(emailsControl?.validator).toBeNull();
expect(passwordControl?.validator).toBeNull();
// Select Email
component.sendDetailsForm.patchValue({ authType: AuthType.Email });
expect(emailsControl?.validator).not.toBeNull();
expect(passwordControl?.validator).toBeNull();
// Select Password
component.sendDetailsForm.patchValue({ authType: AuthType.Password });
expect(passwordControl?.validator).not.toBeNull();
expect(emailsControl?.validator).toBeNull();
// Select None
component.sendDetailsForm.patchValue({ authType: AuthType.None });
expect(emailsControl?.validator).toBeNull();
expect(passwordControl?.validator).toBeNull();
});
});

View File

@@ -3,13 +3,28 @@
import { CommonModule, DatePipe } from "@angular/common";
import { Component, OnInit, Input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import {
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
ValidatorFn,
ValidationErrors,
} from "@angular/forms";
import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
SectionComponent,
@@ -20,7 +35,12 @@ import {
IconButtonModule,
CheckboxModule,
SelectModule,
AsyncActionsModule,
ButtonModule,
ToastService,
DialogService,
} from "@bitwarden/components";
import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container";
@@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined {
@Component({
selector: "tools-send-details",
templateUrl: "./send-details.component.html",
standalone: true,
imports: [
SectionComponent,
SectionHeaderComponent,
@@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined {
IconButtonModule,
CheckboxModule,
CommonModule,
CommonModule,
SelectModule,
AsyncActionsModule,
ButtonModule,
],
})
export class SendDetailsComponent implements OnInit {
@@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit {
FileSendType = SendType.File;
TextSendType = SendType.Text;
readonly AuthType = AuthType;
sendLink: string | null = null;
customDeletionDateOption: DatePresetSelectOption | null = null;
datePresetOptions: DatePresetSelectOption[] = [];
passwordRemoved = false;
emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP);
hasPremium$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
),
);
authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [
{ name: this.i18nService.t("noAuth"), value: AuthType.None },
{ name: this.i18nService.t("specificPeople"), value: AuthType.Email },
{ name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password },
];
availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe(
map(([enabled, hasPremium]) => {
if (!enabled || !hasPremium) {
return this.authTypes.filter((t) => t.value !== AuthType.Email);
}
return this.authTypes;
}),
);
sendDetailsForm = this.formBuilder.group({
name: new FormControl("", Validators.required),
selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required),
authType: [AuthType.None as AuthType],
password: [null as string],
emails: [null as string],
});
get hasPassword(): boolean {
return this.originalSendView?.password != null;
}
constructor(
protected sendFormContainer: SendFormContainer,
protected formBuilder: FormBuilder,
protected i18nService: I18nService,
protected datePipe: DatePipe,
protected environmentService: EnvironmentService,
private configService: ConfigService,
private accountService: AccountService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private generatorService: CredentialGeneratorService,
private sendApiService: SendApiService,
private dialogService: DialogService,
private toastService: ToastService,
) {
this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
return Object.assign(send, {
name: value.name,
deletionDate: new Date(this.formattedDeletionDate),
expirationDate: new Date(this.formattedDeletionDate),
} as SendView);
this.sendDetailsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
return Object.assign(send, {
name: value.name,
deletionDate: new Date(this.formattedDeletionDate),
expirationDate: new Date(this.formattedDeletionDate),
password: value.password,
emails: value.emails
? value.emails
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0)
: null,
} as unknown as SendView);
});
});
this.sendDetailsForm
.get("authType")
.valueChanges.pipe(takeUntilDestroyed())
.subscribe((type) => {
const emailsControl = this.sendDetailsForm.get("emails");
const passwordControl = this.sendDetailsForm.get("password");
if (type === AuthType.Password) {
emailsControl.setValue(null);
emailsControl.clearValidators();
passwordControl.setValidators([Validators.required]);
} else if (type === AuthType.Email) {
passwordControl.setValue(null);
passwordControl.clearValidators();
emailsControl.setValidators([Validators.required, this.emailListValidator()]);
} else {
emailsControl.setValue(null);
emailsControl.clearValidators();
passwordControl.setValue(null);
passwordControl.clearValidators();
}
emailsControl.updateValueAndValidity();
passwordControl.updateValueAndValidity();
});
});
this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm);
}
@@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit {
this.sendDetailsForm.patchValue({
name: this.originalSendView.name,
selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(),
password: this.hasPassword ? "************" : null,
authType: this.originalSendView.authType,
emails: this.originalSendView.emails?.join(", ") ?? null,
});
if (this.hasPassword) {
this.sendDetailsForm.get("password")?.disable();
}
if (this.originalSendView.deletionDate) {
this.customDeletionDateOption = {
name: this.datePipe.transform(this.originalSendView.deletionDate, "short"),
@@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit {
const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000);
return new Date(milliseconds).toString();
}
emailListValidator(): ValidatorFn {
return (control: FormControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const emails = control.value.split(",").map((e: string) => e.trim());
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e));
return invalidEmails.length > 0 ? { email: true } : null;
};
}
generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send", type: Type.password });
const account$ = this.accountService.activeAccount$.pipe(
pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }),
);
const generatedCredential = await firstValueFrom(
this.generatorService.generate$({ on$, account$ }),
);
this.sendDetailsForm.patchValue({
password: generatedCredential.credential,
});
};
removePassword = async () => {
if (!this.originalSendView?.password) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removePassword" },
content: { key: "removePasswordConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
this.passwordRemoved = true;
await this.sendApiService.removePassword(this.originalSendView.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedPassword"),
});
this.originalSendView.password = null;
this.sendDetailsForm.patchValue({
password: null,
});
this.sendDetailsForm.get("password")?.enable();
};
}