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:
committed by
jaasen-livefront
parent
9085ae20d5
commit
8093b4fe81
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
> {{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint
|
||||
>
|
||||
@if (shouldShowCount) {
|
||||
<bit-hint> {{ "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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user