1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Victoria League
2024-09-19 08:59:34 -04:00
committed by GitHub
60 changed files with 2065 additions and 345 deletions

View File

@@ -1037,11 +1037,7 @@ jobs:
--type macos \
--file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \
--apiKey $APP_STORE_CONNECT_AUTH_KEY \
--apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER \
&> output.txt
UUID=$(cat output.txt | grep "Delivery UUID" | sed -E 's/Delivery UUID: (.*)/\1/')
echo "uuid=$UUID" >> $GITHUB_OUTPUT
--apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER
- name: Post message to a Slack channel
id: slack-message
@@ -1059,24 +1055,14 @@ jobs:
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build> success on *${{ github.ref_name }}*"
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "TestFlight Build",
"emoji": true
},
"url": "https://appstoreconnect.apple.com/teams/${{ env.APP_STORE_CONNECT_TEAM_ISSUER }}/apps/1352778147/testflight/macos/${{ env.BUILD_UUID }}"
"text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build ${{ env.BUILD_NUMBER }}> success on *${{ github.ref_name }}*"
}
}
]
}
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
SLACK_BOT_TOKEN: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }}
BUILD_UUID: ${{ steps.testflight-deploy.outputs.uuid }}
BUILD_NUMBER: ${{ needs.setup.outputs.build_number }}
macos-package-dev:

View File

@@ -1136,6 +1136,9 @@
"file": {
"message": "File"
},
"fileToShare": {
"message": "File to share"
},
"selectFile": {
"message": "Select a file"
},

View File

@@ -117,25 +117,18 @@ export class SendAddEditComponent {
)
.subscribe((config) => {
this.config = config;
this.headerText = this.getHeaderText(config.mode, config.sendType);
this.headerText = this.getHeaderText(config.mode);
});
}
/**
* Gets the header text based on the mode and type.
* Gets the header text based on the mode.
* @param mode The mode of the send form.
* @param type The type of the send form.
* @returns The header text.
*/
private getHeaderText(mode: SendFormMode, type: SendType) {
const headerKey =
mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case SendType.Text:
return this.i18nService.t(headerKey, this.i18nService.t("sendTypeText"));
case SendType.File:
return this.i18nService.t(headerKey, this.i18nService.t("sendTypeFile"));
}
private getHeaderText(mode: SendFormMode) {
return this.i18nService.t(
mode === "edit" || mode === "partial-edit" ? "editSend" : "createSend",
);
}
}

View File

@@ -27,18 +27,22 @@ import {
ToastService,
} from "@bitwarden/components";
import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component";
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
@Component({
selector: "app-view-v2",
templateUrl: "view-v2.component.html",
standalone: true,
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
imports: [
CommonModule,
SearchModule,

View File

@@ -0,0 +1,26 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service";
describe("BrowserPremiumUpgradePromptService", () => {
let service: BrowserPremiumUpgradePromptService;
let router: MockProxy<Router>;
beforeEach(async () => {
router = mock<Router>();
await TestBed.configureTestingModule({
providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }],
}).compileComponents();
service = TestBed.inject(BrowserPremiumUpgradePromptService);
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
await service.promptForPremium();
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
});
});
});

View File

@@ -0,0 +1,18 @@
import { inject } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
/**
* This class handles the premium upgrade process for the browser extension.
*/
export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService {
private router = inject(Router);
async promptForPremium() {
/**
* Navigate to the premium update screen.
*/
await this.router.navigate(["/premium"]);
}
}

View File

@@ -25,7 +25,7 @@
"**/node_modules/argon2/package.json",
"**/node_modules/argon2/build/Release/argon2.node"
],
"electronVersion": "32.0.2",
"electronVersion": "32.1.1",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",

View File

@@ -120,7 +120,7 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
id: "syncVault",
label: this.localize("syncVault"),
click: () => this.sendMessage("syncVault"),
enabled: !this._isLocked,
enabled: this.hasAuthenticatedAccounts,
};
}

View File

@@ -27,6 +27,10 @@ export class FirstMenu {
return this._accounts != null && Object.values(this._accounts).some((a) => a.isLockable);
}
protected get hasAuthenticatedAccounts(): boolean {
return this._accounts != null && Object.values(this._accounts).some((a) => a.isAuthenticated);
}
protected get checkForUpdates(): MenuItemConstructorOptions {
return {
id: "checkForUpdates",

View File

@@ -170,8 +170,12 @@ export class BiometricsService extends DesktopBiometricsService {
try {
response = await callback();
restartReload ||= restartReloadCallback(response);
} catch {
restartReload = true;
} catch (error) {
if (error.message === "Biometric authentication failed") {
restartReload = false;
} else {
restartReload = true;
}
}
if (restartReload) {

View File

@@ -188,7 +188,7 @@ export class AppComponent implements OnDestroy, OnInit {
if (premiumConfirmed) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["settings/subscription/premium"]);
await this.router.navigate(["settings/subscription/premium"]);
}
break;
}

View File

@@ -21,8 +21,8 @@
>{{
"upgradeDiscount"
| i18n
: (selectedInterval === planIntervals.Annually
? discountPercentageFromSub + this.discountPercentage
: (selectedInterval === planIntervals.Annually && discountPercentageFromSub == 0
? this.discountPercentage
: this.discountPercentageFromSub)
}}</span
>
@@ -318,7 +318,7 @@
type="info"
title="SECRETS MANAGER SUBSCRIPTION"
>
{{ "secretsManagerSubInfo" | i18n }}
{{ "secretsManagerSubscriptionInfo" | i18n }}
</bit-callout>
<bit-callout
*ngIf="organization.useSecretsManager && isSecretsManagerTrial()"
@@ -356,7 +356,8 @@
<div id="price" class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
>{{ "total" | i18n }}: {{ total | currency: "USD" : "$" }} USD</span
>{{ "total" | i18n }}:
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD</span
>
<span class="tw-text-xs tw-font-light"> / {{ selectedPlanInterval | i18n }}</span>
<button
@@ -442,13 +443,11 @@
bitTypography="body2"
*ngIf="organization.useSecretsManager && !isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<ng-container
*ngIf="selectedInterval == planIntervals.Annually && discountPercentageFromSub > 0"
>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
@@ -524,13 +523,11 @@
bitTypography="body2"
*ngIf="organization.useSecretsManager && !isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<ng-container
*ngIf="selectedInterval == planIntervals.Annually && discountPercentageFromSub > 0"
>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
@@ -760,28 +757,6 @@
</span>
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!--Discount SM Annual-->
<p
class="tw-mb-0 tw-flex tw-justify-between"
bitTypography="body2"
*ngIf="organization.useSecretsManager && isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
additionalServiceAccountTotal(selectedPlan) +
secretsManagerSeatTotal(selectedPlan, sub.smSeats)
) | currency: "$"
}}</span>
</ng-container>
</p>
<!-- password manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
@@ -946,37 +921,19 @@
class="row"
>
<bit-hint class="col-6">
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<ng-container
*ngIf="
selectedInterval == planIntervals.Annually;
else MonthlyOrAnnuallyWithDiscount
"
>
<p
class="tw-mb-0 tw-flex tw-justify-between"
bitTypography="body2"
*ngIf="discountPercentageFromSub > 0"
>
<ng-container>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(total) | currency: "$"
}}</span>
</ng-container>
<ng-template #MonthlyOrAnnuallyWithDiscount>
<span
class="tw-text-xs"
[style.display]="discountPercentageFromSub > 0 ? 'block' : 'none'"
>
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span
[style.display]="discountPercentageFromSub > 0 ? 'block' : 'none'"
class="tw-line-through tw-text-xs"
>{{ calculateTotalAppliedDiscount(total) | currency: "$" }}</span
>
</ng-template>
</p>
</bit-hint>
</div>
@@ -989,7 +946,7 @@
{{ "total" | i18n }}
</span>
<span>
{{ total | currency: "USD" : "$" }}
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold">
/ {{ selectedPlanInterval | i18n }}</span
>

View File

@@ -509,10 +509,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
if (!selectedPlan.isAnnual) {
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
}
return selectedPlan.PasswordManager.additionalStoragePricePerGb / 12;
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
}
additionalServiceAccountTotal(plan: PlanResponse): number {
@@ -834,12 +831,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
calculateTotalAppliedDiscount(total: number) {
const discountPercent =
this.selectedInterval == PlanInterval.Annually
? this.discountPercentage + this.discountPercentageFromSub
: this.discountPercentageFromSub;
const discountedTotal = total / (1 - discountPercent / 100);
const discountedTotal = total * (this.discountPercentageFromSub / 100);
return discountedTotal;
}

View File

@@ -26,7 +26,7 @@
</div>
<ng-container *ngIf="showMethods && method === paymentMethodType.Card">
<div class="tw-grid tw-grid-cols-12 tw-gap-4 tw-mb-4">
<div [ngClass]="trialFlow ? 'tw-col-span-12' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-12' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-number-element">{{
"number" | i18n
}}</app-payment-label-v2>
@@ -40,13 +40,13 @@
height="32"
/>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-expiry-element">{{
"expiration" | i18n
}}</app-payment-label-v2>
<div id="stripe-card-expiry-element" class="form-control stripe-form-control"></div>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-cvc-element">
{{ "securityCodeSlashCVV" | i18n }}
<a

View File

@@ -27,12 +27,14 @@
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<ng-container header *ngIf="!isAdminConsoleActive">
<ng-container header>
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="exposedXTimes"></th>
</tr>
</ng-container>
<ng-template body let-rows$>
@@ -74,7 +76,7 @@
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
@@ -86,7 +88,7 @@
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "exposedXTimes" | i18n: (exposedPasswordMap.get(r.id) | number) }}
{{ "exposedXTimes" | i18n: (r.exposedXTimes | number) }}
</span>
</td>
</tr>

View File

@@ -11,12 +11,14 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CipherReportComponent } from "./cipher-report.component";
type ReportResult = CipherView & { exposedXTimes: number };
@Component({
selector: "app-exposed-passwords-report",
templateUrl: "exposed-passwords-report.component.html",
})
export class ExposedPasswordsReportComponent extends CipherReportComponent implements OnInit {
exposedPasswordMap = new Map<string, number>();
disabled = true;
constructor(
@@ -44,12 +46,12 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
async setCiphers() {
const allCiphers = await this.getAllCiphers();
const exposedPasswordCiphers: CipherView[] = [];
const exposedPasswordCiphers: ReportResult[] = [];
const promises: Promise<void>[] = [];
this.filterStatus = [0];
allCiphers.forEach((ciph: any) => {
const { type, login, isDeleted, edit, viewPassword, id } = ciph;
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
@@ -63,8 +65,8 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => {
if (exposedCount > 0) {
exposedPasswordCiphers.push(ciph);
this.exposedPasswordMap.set(id, exposedCount);
const row = { ...ciph, exposedXTimes: exposedCount } as ReportResult;
exposedPasswordCiphers.push(row);
}
});
promises.push(promise);
@@ -72,6 +74,7 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
await Promise.all(promises);
this.filterCiphersByOrg(exposedPasswordCiphers);
this.dataSource.sort = { column: "exposedXTimes", direction: "desc" };
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -0,0 +1,34 @@
<bit-dialog dialogSize="large">
<span bitDialogTitle>
{{ headerText }}
</span>
<ng-container bitDialogContent>
<vault-cipher-form
*ngIf="!loading"
formId="cipherForm"
[config]="config"
[submitBtn]="submitBtn"
(cipherSaved)="onCipherSaved($event)"
>
<bit-item slot="attachment-button">
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
<p class="tw-m-0">
{{ "attachments" | i18n }}
<span *ngIf="!canAccessAttachments" bitBadge variant="success" class="tw-ml-2">
{{ "premium" | i18n }}
</span>
</p>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,124 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { CipherFormConfig, DefaultCipherFormConfigService } from "@bitwarden/vault";
import { AddEditComponentV2 } from "./add-edit-v2.component";
describe("AddEditComponentV2", () => {
let component: AddEditComponentV2;
let fixture: ComponentFixture<AddEditComponentV2>;
let organizationService: MockProxy<OrganizationService>;
let policyService: MockProxy<PolicyService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let activatedRoute: MockProxy<ActivatedRoute>;
let dialogRef: MockProxy<DialogRef<any>>;
let dialogService: MockProxy<DialogService>;
let cipherService: MockProxy<CipherService>;
let messagingService: MockProxy<MessagingService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
const mockParams = {
cloneMode: false,
cipherFormConfig: mock<CipherFormConfig>(),
};
beforeEach(async () => {
const mockOrganization: Organization = {
id: "org-id",
name: "Test Organization",
} as Organization;
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([mockOrganization]);
policyService = mock<PolicyService>();
policyService.policyAppliesToActiveUser$.mockImplementation((policyType: PolicyType) =>
of(true),
);
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true);
activatedRoute = mock<ActivatedRoute>();
activatedRoute.queryParams = of({});
dialogRef = mock<DialogRef<any>>();
dialogService = mock<DialogService>();
messagingService = mock<MessagingService>();
folderService = mock<FolderService>();
folderService.folderViews$ = of([]);
collectionService = mock<CollectionService>();
collectionService.decryptedCollections$ = of([]);
const mockDefaultCipherFormConfigService = {
buildConfig: jest.fn().mockResolvedValue({
allowPersonal: true,
allowOrganization: true,
}),
};
await TestBed.configureTestingModule({
imports: [AddEditComponentV2],
providers: [
{ provide: DIALOG_DATA, useValue: mockParams },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } },
{ provide: DialogService, useValue: dialogService },
{ provide: CipherService, useValue: cipherService },
{ provide: MessagingService, useValue: messagingService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: Router, useValue: mock<Router>() },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: CollectionService, useValue: collectionService },
{ provide: FolderService, useValue: folderService },
{ provide: CryptoService, useValue: mock<CryptoService>() },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
{ provide: PolicyService, useValue: policyService },
{ provide: DefaultCipherFormConfigService, useValue: mockDefaultCipherFormConfigService },
{
provide: PasswordGenerationServiceAbstraction,
useValue: mock<PasswordGenerationServiceAbstraction>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(AddEditComponentV2);
component = fixture.componentInstance;
});
describe("ngOnInit", () => {
it("initializes the component with cipher", async () => {
await component.ngOnInit();
expect(component).toBeTruthy();
});
});
describe("cancel", () => {
it("handles cancel action", async () => {
const spyClose = jest.spyOn(dialogRef, "close");
await component.cancel();
expect(spyClose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,177 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, DialogModule, DialogService, ItemModule } from "@bitwarden/components";
import {
CipherAttachmentsComponent,
CipherFormConfig,
CipherFormGenerationService,
CipherFormMode,
CipherFormModule,
} from "@bitwarden/vault";
import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service";
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { AttachmentsV2Component } from "./attachments-v2.component";
/**
* The result of the AddEditCipherDialogV2 component.
*/
export enum AddEditCipherDialogResult {
Edited = "edited",
Added = "added",
Canceled = "canceled",
}
/**
* The close result of the AddEditCipherDialogV2 component.
*/
export interface AddEditCipherDialogCloseResult {
/**
* The action that was taken.
*/
action: AddEditCipherDialogResult;
/**
* The ID of the cipher that was edited or added.
*/
id?: CipherId;
}
/**
* Component for viewing a cipher, presented in a dialog.
*/
@Component({
selector: "app-vault-add-edit-v2",
templateUrl: "add-edit-v2.component.html",
standalone: true,
imports: [
CipherViewComponent,
CommonModule,
AsyncActionsModule,
DialogModule,
SharedModule,
CipherFormModule,
CipherAttachmentsComponent,
ItemModule,
],
providers: [{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }],
})
export class AddEditComponentV2 implements OnInit {
config: CipherFormConfig;
headerText: string;
canAccessAttachments: boolean = false;
/**
* Constructor for the AddEditComponentV2 component.
* @param params The parameters for the component.
* @param dialogRef The reference to the dialog.
* @param i18nService The internationalization service.
* @param dialogService The dialog service.
* @param billingAccountProfileStateService The billing account profile state service.
*/
constructor(
@Inject(DIALOG_DATA) public params: CipherFormConfig,
private dialogRef: DialogRef<AddEditCipherDialogCloseResult>,
private i18nService: I18nService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntilDestroyed())
.subscribe((canAccessPremium) => {
this.canAccessAttachments = canAccessPremium;
});
}
/**
* Lifecycle hook for component initialization.
*/
async ngOnInit() {
this.config = this.params;
this.headerText = this.setHeader(this.config?.mode, this.config.cipherType);
}
/**
* Getter to check if the component is loading.
*/
get loading() {
return this.config == null;
}
/**
* Method to handle cancel action. Called when a user clicks the cancel button.
*/
async cancel() {
this.dialogRef.close({ action: AddEditCipherDialogResult.Canceled });
}
/**
* Sets the header text based on the mode and type of the cipher.
* @param mode The form mode.
* @param type The cipher type.
* @returns The header text.
*/
setHeader(mode: CipherFormMode, type: CipherType) {
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case CipherType.Login:
return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
case CipherType.Card:
return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
case CipherType.Identity:
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
}
}
/**
* Opens the attachments dialog.
*/
async openAttachmentsDialog() {
this.dialogService.open<AttachmentsV2Component, { cipherId: CipherId }>(
AttachmentsV2Component,
{
data: {
cipherId: this.config.originalCipher?.id as CipherId,
},
},
);
}
/**
* Handles the event when a cipher is saved.
* @param cipherView The cipher view that was saved.
*/
async onCipherSaved(cipherView: CipherView) {
this.dialogRef.close({
action:
this.config.mode === "add"
? AddEditCipherDialogResult.Added
: AddEditCipherDialogResult.Edited,
id: cipherView.id as CipherId,
});
}
}
/**
* Strongly typed helper to open a cipher add/edit dialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
* @returns A reference to the opened dialog
*/
export function openAddEditCipherDialog(
dialogService: DialogService,
config: DialogConfig<CipherFormConfig>,
): DialogRef<AddEditCipherDialogCloseResult> {
return dialogService.open(AddEditComponentV2, config);
}

View File

@@ -0,0 +1,19 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle>
{{ "attachments" | i18n }}
</span>
<ng-container bitDialogContent>
<app-cipher-attachments
*ngIf="cipherId"
[cipherId]="cipherId"
[submitBtn]="submitBtn"
(onUploadSuccess)="uploadSuccessful()"
(onRemoveSuccess)="removalSuccessful()"
></app-cipher-attachments>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
{{ "upload" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,65 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { 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";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
AttachmentsV2Component,
AttachmentDialogResult,
AttachmentsDialogParams,
} from "./attachments-v2.component";
describe("AttachmentsV2Component", () => {
let component: AttachmentsV2Component;
let fixture: ComponentFixture<AttachmentsV2Component>;
const mockCipherId: CipherId = "cipher-id" as CipherId;
const mockParams: AttachmentsDialogParams = {
cipherId: mockCipherId,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AttachmentsV2Component, NoopAnimationsModule],
providers: [
{ provide: DIALOG_DATA, useValue: mockParams },
{ provide: DialogRef, useValue: mock<DialogRef>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: AccountService, useValue: mock<AccountService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(AttachmentsV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("initializes without errors and with the correct cipherId", () => {
expect(component).toBeTruthy();
expect(component.cipherId).toBe(mockParams.cipherId);
});
it("closes the dialog with 'uploaded' result on uploadSuccessful", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.uploadSuccessful();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded });
});
it("closes the dialog with 'removed' result on removalSuccessful", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.removalSuccessful();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
});
});

View File

@@ -0,0 +1,87 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { CipherId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { CipherAttachmentsComponent } from "@bitwarden/vault";
import { SharedModule } from "../../shared";
export interface AttachmentsDialogParams {
cipherId: CipherId;
}
/**
* Enum representing the possible results of the attachment dialog.
*/
export enum AttachmentDialogResult {
Uploaded = "uploaded",
Removed = "removed",
Closed = "closed",
}
export interface AttachmentDialogCloseResult {
action: AttachmentDialogResult;
}
/**
* Component for the attachments dialog.
*/
@Component({
selector: "app-vault-attachments-v2",
templateUrl: "attachments-v2.component.html",
standalone: true,
imports: [CommonModule, SharedModule, CipherAttachmentsComponent],
})
export class AttachmentsV2Component {
cipherId: CipherId;
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
/**
* Constructor for AttachmentsV2Component.
* @param dialogRef - Reference to the dialog.
* @param params - Parameters passed to the dialog.
*/
constructor(
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
) {
this.cipherId = params.cipherId;
}
/**
* Opens the attachments dialog.
* @param dialogService - The dialog service.
* @param params - The parameters for the dialog.
* @returns The dialog reference.
*/
static open(
dialogService: DialogService,
params: AttachmentsDialogParams,
): DialogRef<AttachmentDialogCloseResult> {
return dialogService.open(AttachmentsV2Component, {
data: params,
});
}
/**
* Called when an attachment is successfully uploaded.
* Closes the dialog with an 'uploaded' result.
*/
uploadSuccessful() {
this.dialogRef.close({
action: AttachmentDialogResult.Uploaded,
});
}
/**
* Called when an attachment is successfully removed.
* Closes the dialog with a 'removed' result.
*/
removalSuccessful() {
this.dialogRef.close({
action: AttachmentDialogResult.Removed,
});
}
}

View File

@@ -47,20 +47,25 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault";
import {
CollectionAssignmentResult,
DefaultCipherFormConfigService,
PasswordRepromptService,
} from "@bitwarden/vault";
import { SharedModule } from "../../shared/shared.module";
import { AssignCollectionsWebComponent } from "../components/assign-collections";
@@ -74,7 +79,17 @@ import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { getNestedCollectionTree } from "../utils/collection-utils";
import {
AddEditCipherDialogCloseResult,
AddEditCipherDialogResult,
openAddEditCipherDialog,
} from "./add-edit-v2.component";
import { AddEditComponent } from "./add-edit.component";
import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
} from "./attachments-v2.component";
import { AttachmentsComponent } from "./attachments.component";
import {
BulkDeleteDialogResult,
@@ -131,7 +146,11 @@ const SearchTextDebounceInterval = 200;
VaultItemsModule,
SharedModule,
],
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
providers: [
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService,
],
})
export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
@@ -170,6 +189,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private extensionRefreshEnabled: boolean;
constructor(
private syncService: SyncService,
@@ -200,6 +220,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private accountService: AccountService,
private cipherFormConfigService: DefaultCipherFormConfigService,
) {}
async ngOnInit() {
@@ -416,6 +437,11 @@ export class VaultComponent implements OnInit, OnDestroy {
this.refreshing = false;
},
);
// Check if the extension refresh feature flag is enabled
this.extensionRefreshEnabled = await this.configService.getFeatureFlag(
FeatureFlag.ExtensionRefresh,
);
}
ngOnDestroy() {
@@ -511,6 +537,15 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchText$.next(searchText);
}
/**
* Handles opening the attachments dialog for a cipher.
* Runs several checks to ensure that the user has the correct permissions
* and then opens the attachments dialog.
* Uses the new AttachmentsV2Component if the extensionRefresh feature flag is enabled.
*
* @param cipher
* @returns
*/
async editCipherAttachments(cipher: CipherView) {
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
@@ -536,6 +571,24 @@ export class VaultComponent implements OnInit, OnDestroy {
);
let madeAttachmentChanges = false;
if (this.extensionRefreshEnabled) {
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: cipher.id as CipherId,
});
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
if (
result.action === AttachmentDialogResult.Uploaded ||
result.action === AttachmentDialogResult.Removed
) {
this.refresh();
}
return;
}
const [modal] = await this.modalService.openViewRef(
AttachmentsComponent,
this.attachmentsModalRef,
@@ -598,7 +651,11 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async addCipher(cipherType?: CipherType) {
const component = await this.editCipher(null);
if (this.extensionRefreshEnabled) {
return this.addCipherV2(cipherType);
}
const component = (await this.editCipher(null)) as AddEditComponent;
component.type = cipherType || this.activeFilter.cipherType;
if (
this.activeFilter.organizationId !== "MyVault" &&
@@ -622,18 +679,60 @@ export class VaultComponent implements OnInit, OnDestroy {
component.folderId = this.activeFilter.folderId;
}
/**
* Opens the add cipher dialog.
* @param cipherType The type of cipher to add.
* @returns The dialog reference.
*/
async addCipherV2(cipherType?: CipherType) {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
"add",
null,
cipherType,
);
cipherFormConfig.initialValues = {
organizationId:
this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null
? (this.activeFilter.organizationId as OrganizationId)
: null,
collectionIds:
this.activeFilter.collectionId !== "AllCollections" &&
this.activeFilter.collectionId != null
? [this.activeFilter.collectionId as CollectionId]
: [],
folderId: this.activeFilter.folderId,
};
// Open the dialog.
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
// Wait for the dialog to close.
const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// Refresh the vault to show the new cipher.
if (result?.action === AddEditCipherDialogResult.Added) {
this.refresh();
this.go({ itemId: result.id, action: "view" });
return;
}
// If the dialog was closed by any other action navigate back to the vault.
this.go({ cipherId: null, itemId: null, action: null });
}
async navigateToCipher(cipher: CipherView) {
this.go({ itemId: cipher?.id });
}
async editCipher(cipher: CipherView) {
return this.editCipherId(cipher?.id);
async editCipher(cipher: CipherView, cloneMode?: boolean) {
return this.editCipherId(cipher?.id, cloneMode);
}
async editCipherId(id: string) {
async editCipherId(id: string, cloneMode?: boolean) {
const cipher = await this.cipherService.get(id);
// if cipher exists (cipher is null when new) and MP reprompt
// is on for this cipher, then show password reprompt
if (
cipher &&
cipher.reprompt !== 0 &&
@@ -644,6 +743,11 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
if (this.extensionRefreshEnabled) {
await this.editCipherIdV2(cipher, cloneMode);
return;
}
const [modal, childComponent] = await this.modalService.openViewRef(
AddEditComponent,
this.cipherAddEditModalRef,
@@ -673,6 +777,46 @@ export class VaultComponent implements OnInit, OnDestroy {
return childComponent;
}
/**
* Edit a cipher using the new AddEditCipherDialogV2 component.
*
* @param cipher
* @param cloneMode
*/
private async editCipherIdV2(cipher: Cipher, cloneMode?: boolean) {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
cloneMode ? "clone" : "edit",
cipher.id as CipherId,
cipher.type,
);
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed);
/**
* Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.refresh();
}
/**
* View the cipher if the dialog was closed by editing the cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.go({ itemId: cipher.id, action: "view" });
return;
}
/**
* Navigate to the vault if the dialog was closed by any other action.
*/
this.go({ cipherId: null, itemId: null, action: null });
}
/**
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
* @param cipher - CipherView
@@ -718,8 +862,9 @@ export class VaultComponent implements OnInit, OnDestroy {
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result?.action === ViewCipherDialogResult.deleted) {
if (result?.action === ViewCipherDialogResult.Deleted) {
this.refresh();
this.go({ cipherId: null, itemId: null, action: null });
}
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
@@ -873,7 +1018,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
const component = await this.editCipher(cipher);
const component = await this.editCipher(cipher, true);
component.cloneMode = true;
}

View File

@@ -98,7 +98,7 @@ describe("ViewComponent", () => {
organizationId: mockCipher.organizationId,
},
});
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited });
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited });
});
});
@@ -111,7 +111,7 @@ describe("ViewComponent", () => {
await component.delete();
expect(deleteSpy).toHaveBeenCalled();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted });
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted });
});
});
});

View File

@@ -1,6 +1,6 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnDestroy, OnInit } from "@angular/core";
import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
@@ -19,16 +19,19 @@ import {
ToastService,
} from "@bitwarden/components";
import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
export interface ViewCipherDialogParams {
cipher: CipherView;
}
export enum ViewCipherDialogResult {
edited = "edited",
deleted = "deleted",
Edited = "edited",
Deleted = "deleted",
PremiumUpgrade = "premiumUpgrade",
}
export interface ViewCipherDialogCloseResult {
@@ -43,6 +46,9 @@ export interface ViewCipherDialogCloseResult {
templateUrl: "view.component.html",
standalone: true,
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
],
})
export class ViewComponent implements OnInit, OnDestroy {
cipher: CipherView;
@@ -117,7 +123,7 @@ export class ViewComponent implements OnInit, OnDestroy {
this.logService.error(e);
}
this.dialogRef.close({ action: ViewCipherDialogResult.deleted });
this.dialogRef.close({ action: ViewCipherDialogResult.Deleted });
await this.router.navigate(["/vault"]);
};
@@ -137,7 +143,7 @@ export class ViewComponent implements OnInit, OnDestroy {
* Method to handle cipher editing. Called when a user clicks the edit button.
*/
async edit(): Promise<void> {
this.dialogRef.close({ action: ViewCipherDialogResult.edited });
this.dialogRef.close({ action: ViewCipherDialogResult.Edited });
await this.router.navigate([], {
queryParams: {
itemId: this.cipher.id,

View File

@@ -886,12 +886,13 @@ export class VaultComponent implements OnInit, OnDestroy {
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result.action === ViewCipherDialogResult.deleted) {
if (result?.action === ViewCipherDialogResult.Deleted) {
this.refresh();
this.go({ cipherId: null, itemId: null, action: null });
}
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
if (!result.action) {
if (!result?.action) {
this.go({ cipherId: null, itemId: null, action: null });
}
}

View File

@@ -0,0 +1,95 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of, lastValueFrom } from "rxjs";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service";
describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;
routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;
dialogRefMock = {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
TestBed.configureTestingModule({
providers: [
WebVaultPremiumUpgradePromptService,
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: Router, useValue: routerMock },
{ provide: DialogRef, useValue: dialogRefMock },
],
});
service = TestBed.inject(WebVaultPremiumUpgradePromptService);
});
it("prompts for premium upgrade and navigates to organization billing if organizationId is provided", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
const organizationId = "test-org-id" as OrganizationId;
await service.promptForPremium(organizationId);
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
expect(routerMock.navigate).toHaveBeenCalledWith([
"organizations",
organizationId,
"billing",
"subscription",
]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
});
it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
await service.promptForPremium();
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
});
it("does not navigate or close dialog if upgrade is no action is taken", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(false)));
await service.promptForPremium("test-org-id" as OrganizationId);
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(dialogRefMock.close).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
/**
* This service is used to prompt the user to upgrade to premium.
*/
@Injectable()
export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePromptService {
constructor(
private dialogService: DialogService,
private router: Router,
private dialog: DialogRef<ViewCipherDialogCloseResult>,
) {}
/**
* Prompts the user to upgrade to premium.
* @param organizationId The ID of the organization to upgrade.
*/
async promptForPremium(organizationId?: OrganizationId) {
let upgradeConfirmed;
if (organizationId) {
upgradeConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
if (upgradeConfirmed) {
await this.router.navigate(["organizations", organizationId, "billing", "subscription"]);
}
} else {
upgradeConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
if (upgradeConfirmed) {
await this.router.navigate(["settings/subscription/premium"]);
}
}
if (upgradeConfirmed) {
this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade });
}
}
}

View File

@@ -511,6 +511,24 @@
"viewItem": {
"message": "View item"
},
"newItemHeader": {
"message": "New $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "login"
}
}
},
"editItemHeader": {
"message": "Edit $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "login"
}
}
},
"viewItemType": {
"message": "View $ITEMTYPE$",
"placeholders": {
@@ -7399,6 +7417,9 @@
"fileUpload": {
"message": "File upload"
},
"upload": {
"message": "Upload"
},
"acceptedFormats": {
"message": "Accepted Formats:"
},
@@ -9131,8 +9152,8 @@
"current": {
"message": "Current"
},
"secretsManagerSubInfo": {
"message": "Your Secrets Manager subscription will upgrade base on the plan selected"
"secretsManagerSubscriptionInfo": {
"message": "Your Secrets Manager subscription will upgrade based on the plan selected"
},
"bitwardenPasswordManager": {
"message": "Bitwarden Password Manager"

View File

@@ -135,6 +135,8 @@ const plugins = [
template: "./src/404.html",
filename: "404.html",
chunks: ["styles"],
// 404 page is a wildcard, this ensures it uses absolute paths.
publicPath: "/",
}),
new CopyWebpackPlugin({
patterns: [

View File

@@ -1,7 +1,7 @@
import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
@@ -53,7 +53,9 @@ describe("FidoAuthenticatorService", () => {
userInterface = mock<Fido2UserInterfaceService>();
userInterfaceSession = mock<Fido2UserInterfaceSession>();
userInterface.newSession.mockResolvedValue(userInterfaceSession);
syncService = mock<SyncService>();
syncService = mock<SyncService>({
activeUserLastSync$: () => of(new Date()),
});
accountService = mock<AccountService>();
authenticator = new Fido2AuthenticatorService(
cipherService,

View File

@@ -94,7 +94,14 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
}
await userInterfaceSession.ensureUnlockedVault();
await this.syncService.fullSync(false);
// Avoid syncing if we did it reasonably soon as the only reason for syncing is to validate excludeCredentials
const lastSync = await firstValueFrom(this.syncService.activeUserLastSync$());
const threshold = new Date().getTime() - 1000 * 60 * 30; // 30 minutes ago
if (!lastSync || lastSync.getTime() < threshold) {
await this.syncService.fullSync(false);
}
const existingCipherIds = await this.findExcludedCredentials(
params.excludeCredentialDescriptorList,
@@ -223,15 +230,17 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
let cipherOptions: CipherView[];
await userInterfaceSession.ensureUnlockedVault();
await this.syncService.fullSync(false);
if (params.allowCredentialDescriptorList?.length > 0) {
cipherOptions = await this.findCredentialsById(
params.allowCredentialDescriptorList,
params.rpId,
);
} else {
cipherOptions = await this.findCredentialsByRp(params.rpId);
// Try to find the passkey locally before causing a sync to speed things up
// only skip syncing if we found credentials AND all of them have a counter = 0
cipherOptions = await this.findCredential(params, cipherOptions);
if (
cipherOptions.length === 0 ||
cipherOptions.some((c) => c.login.fido2Credentials.some((p) => p.counter > 0))
) {
// If no passkey is found, or any had a non-zero counter, sync to get the latest data
await this.syncService.fullSync(false);
cipherOptions = await this.findCredential(params, cipherOptions);
}
if (cipherOptions.length === 0) {
@@ -335,6 +344,21 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
}
}
private async findCredential(
params: Fido2AuthenticatorGetAssertionParams,
cipherOptions: CipherView[],
) {
if (params.allowCredentialDescriptorList?.length > 0) {
cipherOptions = await this.findCredentialsById(
params.allowCredentialDescriptorList,
params.rpId,
);
} else {
cipherOptions = await this.findCredentialsByRp(params.rpId);
}
return cipherOptions;
}
private requiresUserVerificationPrompt(
params: Fido2AuthenticatorGetAssertionParams,
cipherOptions: CipherView[],

View File

@@ -0,0 +1,7 @@
/**
* This interface defines the a contract for a service that prompts the user to upgrade to premium.
* It ensures that PremiumUpgradePromptService contains a promptForPremium method.
*/
export abstract class PremiumUpgradePromptService {
abstract promptForPremium(organizationId?: string): Promise<void>;
}

View File

@@ -85,7 +85,7 @@ describe("Protonpass Json Importer", () => {
// "My Secure Note" is assigned to folder "Personal"
expect(result.folderRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.folderRelationships[3]).toEqual([3, 1]);
expect(result.folderRelationships[4]).toEqual([4, 1]);
});
it("should create collections if part of an organization", async () => {
@@ -102,7 +102,7 @@ describe("Protonpass Json Importer", () => {
// "My Secure Note" is assigned to folder "Personal"
expect(result.collectionRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.collectionRelationships[3]).toEqual([3, 1]);
expect(result.collectionRelationships[4]).toEqual([4, 1]);
});
it("should not add deleted items", async () => {
@@ -114,7 +114,7 @@ describe("Protonpass Json Importer", () => {
expect(cipher.name).not.toBe("My Deleted Note");
}
expect(ciphers.length).toBe(4);
expect(ciphers.length).toBe(5);
});
it("should set favorites", async () => {
@@ -126,4 +126,97 @@ describe("Protonpass Json Importer", () => {
expect(ciphers[1].favorite).toBe(false);
expect(ciphers[2].favorite).toBe(true);
});
it("should skip unsupported items", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
const ciphers = result.ciphers;
expect(ciphers.length).toBe(5);
expect(ciphers[4].type).toEqual(CipherType.Login);
});
it("should parse identity data", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
result.ciphers.shift();
result.ciphers.shift();
result.ciphers.shift();
const cipher = result.ciphers.shift();
expect(cipher.type).toEqual(CipherType.Identity);
expect(cipher.identity.firstName).toBe("Test");
expect(cipher.identity.middleName).toBe("1");
expect(cipher.identity.lastName).toBe("1");
expect(cipher.identity.email).toBe("test@gmail.com");
expect(cipher.identity.phone).toBe("7507951789");
expect(cipher.identity.company).toBe("Bitwarden");
expect(cipher.identity.ssn).toBe("98378264782");
expect(cipher.identity.passportNumber).toBe("7173716378612");
expect(cipher.identity.licenseNumber).toBe("21234");
expect(cipher.identity.address1).toBe("Bitwarden");
expect(cipher.identity.address2).toBe("23 Street");
expect(cipher.identity.address3).toBe("12th Foor Test County");
expect(cipher.identity.city).toBe("New York");
expect(cipher.identity.state).toBe("Test");
expect(cipher.identity.postalCode).toBe("4038456");
expect(cipher.identity.country).toBe("US");
expect(cipher.fields.length).toEqual(13);
expect(cipher.fields.at(0).name).toEqual("gender");
expect(cipher.fields.at(0).value).toEqual("Male");
expect(cipher.fields.at(0).type).toEqual(FieldType.Text);
expect(cipher.fields.at(1).name).toEqual("TestPersonal");
expect(cipher.fields.at(1).value).toEqual("Personal");
expect(cipher.fields.at(1).type).toEqual(FieldType.Text);
expect(cipher.fields.at(2).name).toEqual("TestAddress");
expect(cipher.fields.at(2).value).toEqual("Address");
expect(cipher.fields.at(2).type).toEqual(FieldType.Text);
expect(cipher.fields.at(3).name).toEqual("xHandle");
expect(cipher.fields.at(3).value).toEqual("@twiter");
expect(cipher.fields.at(3).type).toEqual(FieldType.Text);
expect(cipher.fields.at(4).name).toEqual("secondPhoneNumber");
expect(cipher.fields.at(4).value).toEqual("243538978");
expect(cipher.fields.at(4).type).toEqual(FieldType.Text);
expect(cipher.fields.at(5).name).toEqual("instagram");
expect(cipher.fields.at(5).value).toEqual("@insta");
expect(cipher.fields.at(5).type).toEqual(FieldType.Text);
expect(cipher.fields.at(6).name).toEqual("TestContact");
expect(cipher.fields.at(6).value).toEqual("Contact");
expect(cipher.fields.at(6).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(7).name).toEqual("jobTitle");
expect(cipher.fields.at(7).value).toEqual("Engineer");
expect(cipher.fields.at(7).type).toEqual(FieldType.Text);
expect(cipher.fields.at(8).name).toEqual("workPhoneNumber");
expect(cipher.fields.at(8).value).toEqual("78236476238746");
expect(cipher.fields.at(8).type).toEqual(FieldType.Text);
expect(cipher.fields.at(9).name).toEqual("TestWork");
expect(cipher.fields.at(9).value).toEqual("Work");
expect(cipher.fields.at(9).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(10).name).toEqual("TestSection");
expect(cipher.fields.at(10).value).toEqual("Section");
expect(cipher.fields.at(10).type).toEqual(FieldType.Text);
expect(cipher.fields.at(11).name).toEqual("TestSectionHidden");
expect(cipher.fields.at(11).value).toEqual("SectionHidden");
expect(cipher.fields.at(11).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(12).name).toEqual("TestExtra");
expect(cipher.fields.at(12).value).toEqual("Extra");
expect(cipher.fields.at(12).type).toEqual(FieldType.Text);
});
});

View File

@@ -138,6 +138,144 @@ export const testData: ProtonPassJsonFile = {
modifyTime: 1689182908,
pinned: false,
},
{
itemId:
"gliCOyyJOsoBf5QIijvCF4QsPij3q_MR4nCXZ2sXm7YCJCfHjrRD_p2XG9vLsaytErsQvMhcLISVS7q8-7SCkg==",
shareId:
"TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==",
data: {
metadata: {
name: "Identity",
note: "",
itemUuid: "c2e52768",
},
extraFields: [
{
fieldName: "TestExtra",
type: "text",
data: {
content: "Extra",
},
},
],
type: "identity",
content: {
fullName: "Test 1",
email: "test@gmail.com",
phoneNumber: "7507951789",
firstName: "Test",
middleName: "1",
lastName: "Test",
birthdate: "",
gender: "Male",
extraPersonalDetails: [
{
fieldName: "TestPersonal",
type: "text",
data: {
content: "Personal",
},
},
],
organization: "Bitwarden",
streetAddress: "23 Street",
zipOrPostalCode: "4038456",
city: "New York",
stateOrProvince: "Test",
countryOrRegion: "US",
floor: "12th Foor",
county: "Test County",
extraAddressDetails: [
{
fieldName: "TestAddress",
type: "text",
data: {
content: "Address",
},
},
],
socialSecurityNumber: "98378264782",
passportNumber: "7173716378612",
licenseNumber: "21234",
website: "",
xHandle: "@twiter",
secondPhoneNumber: "243538978",
linkedin: "",
reddit: "",
facebook: "",
yahoo: "",
instagram: "@insta",
extraContactDetails: [
{
fieldName: "TestContact",
type: "hidden",
data: {
content: "Contact",
},
},
],
company: "Bitwarden",
jobTitle: "Engineer",
personalWebsite: "",
workPhoneNumber: "78236476238746",
workEmail: "",
extraWorkDetails: [
{
fieldName: "TestWork",
type: "hidden",
data: {
content: "Work",
},
},
],
extraSections: [
{
sectionName: "TestSection",
sectionFields: [
{
fieldName: "TestSection",
type: "text",
data: {
content: "Section",
},
},
{
fieldName: "TestSectionHidden",
type: "hidden",
data: {
content: "SectionHidden",
},
},
],
},
],
},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 6,
createTime: 1725707298,
modifyTime: 1725707298,
pinned: false,
},
{
itemId:
"WTKLZtKfHIC3Gv7gRXUANifNjj0gN3P_52I4MznAzig9GSb_OgJ0qcZ8taOZyfsFTLOWBslXwI-HSMWXVmnKzQ==",
shareId:
"TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==",
data: {
metadata: { name: "Alias", note: "", itemUuid: "576f14fa" },
extraFields: [],
type: "alias",
content: {},
},
state: 1,
aliasEmail: "alias.removing005@passinbox.com",
contentFormatVersion: 6,
createTime: 1725708208,
modifyTime: 1725708208,
pinned: false,
},
],
},
REDACTED_VAULT_ID_B: {

View File

@@ -0,0 +1,66 @@
import { processNames } from "./protonpass-import-utils";
describe("processNames", () => {
it("should use only fullName to map names if it contains at least three words, ignoring individual name fields", () => {
const result = processNames("Alice Beth Carter", "Kevin", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should map extra words to the middle name if fullName contains more than three words", () => {
const result = processNames("Alice Beth Middle Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth Middle",
mappedLastName: "Carter",
});
});
it("should map names correctly even if fullName has words separated by more than one space", () => {
const result = processNames("Alice Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "",
mappedLastName: "Carter",
});
});
it("should handle a single name in fullName and use middleName and lastName to populate rest of names", () => {
const result = processNames("Alice", "", "Beth", "Carter");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should correctly map fullName when it only contains two words", () => {
const result = processNames("Alice Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "",
mappedLastName: "Carter",
});
});
it("should map middle name from middleName if fullName only contains two words", () => {
const result = processNames("Alice Carter", "", "Beth", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should fall back to firstName, middleName, and lastName if fullName is empty", () => {
const result = processNames("", "Alice", "Beth", "Carter");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
});

View File

@@ -0,0 +1,21 @@
export function processNames(
fullname: string | null,
firstname: string | null,
middlename: string | null,
lastname: string | null,
) {
let mappedFirstName = firstname;
let mappedMiddleName = middlename;
let mappedLastName = lastname;
if (fullname) {
const parts = fullname.trim().split(/\s+/);
// Assign parts to first, middle, and last name based on the number of parts
mappedFirstName = parts[0] || firstname;
mappedLastName = parts.length > 1 ? parts[parts.length - 1] : lastname;
mappedMiddleName = parts.length > 2 ? parts.slice(1, -1).join(" ") : middlename;
}
return { mappedFirstName, mappedMiddleName, mappedLastName };
}

View File

@@ -1,24 +1,110 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
import { Importer } from "../importer";
import { processNames } from "./protonpass-import-utils";
import {
ProtonPassCreditCardItemContent,
ProtonPassIdentityItemContent,
ProtonPassIdentityItemExtraSection,
ProtonPassItemExtraField,
ProtonPassItemState,
ProtonPassJsonFile,
ProtonPassLoginItemContent,
} from "./types/protonpass-json-type";
export class ProtonPassJsonImporter extends BaseImporter implements Importer {
private mappedIdentityItemKeys = [
"fullName",
"firstName",
"middleName",
"lastName",
"email",
"phoneNumber",
"company",
"socialSecurityNumber",
"passportNumber",
"licenseNumber",
"organization",
"streetAddress",
"floor",
"county",
"city",
"stateOrProvince",
"zipOrPostalCode",
"countryOrRegion",
];
private identityItemExtraFieldsKeys = [
"extraPersonalDetails",
"extraAddressDetails",
"extraContactDetails",
"extraWorkDetails",
"extraSections",
];
constructor(private i18nService: I18nService) {
super();
}
private processIdentityItemUnmappedAndExtraFields(
cipher: CipherView,
identityItem: ProtonPassIdentityItemContent,
) {
Object.keys(identityItem).forEach((key) => {
if (
!this.mappedIdentityItemKeys.includes(key) &&
!this.identityItemExtraFieldsKeys.includes(key)
) {
this.processKvp(
cipher,
key,
identityItem[key as keyof ProtonPassIdentityItemContent] as string,
);
return;
}
if (this.identityItemExtraFieldsKeys.includes(key)) {
if (key !== "extraSections") {
const extraFields = identityItem[
key as keyof ProtonPassIdentityItemContent
] as ProtonPassItemExtraField[];
extraFields?.forEach((extraField) => {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
});
} else {
const extraSections = identityItem[
key as keyof ProtonPassIdentityItemContent
] as ProtonPassIdentityItemExtraSection[];
extraSections?.forEach((extraSection) => {
extraSection.sectionFields?.forEach((extraField) => {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
});
});
}
}
});
}
parse(data: string): Promise<ImportResult> {
const result = new ImportResult();
const results: ProtonPassJsonFile = JSON.parse(data);
@@ -38,7 +124,6 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
if (item.state == ProtonPassItemState.TRASHED) {
continue;
}
this.processFolder(result, vault.name);
const cipher = this.initLoginCipher();
cipher.name = this.getValueOrDefault(item.data.metadata.name, "--");
@@ -96,8 +181,55 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
break;
}
case "identity": {
const identityContent = item.data.content as ProtonPassIdentityItemContent;
cipher.type = CipherType.Identity;
cipher.identity = new IdentityView();
const { mappedFirstName, mappedMiddleName, mappedLastName } = processNames(
this.getValueOrDefault(identityContent.fullName),
this.getValueOrDefault(identityContent.firstName),
this.getValueOrDefault(identityContent.middleName),
this.getValueOrDefault(identityContent.lastName),
);
cipher.identity.firstName = mappedFirstName;
cipher.identity.middleName = mappedMiddleName;
cipher.identity.lastName = mappedLastName;
cipher.identity.email = this.getValueOrDefault(identityContent.email);
cipher.identity.phone = this.getValueOrDefault(identityContent.phoneNumber);
cipher.identity.company = this.getValueOrDefault(identityContent.company);
cipher.identity.ssn = this.getValueOrDefault(identityContent.socialSecurityNumber);
cipher.identity.passportNumber = this.getValueOrDefault(identityContent.passportNumber);
cipher.identity.licenseNumber = this.getValueOrDefault(identityContent.licenseNumber);
const address3 =
`${identityContent.floor ?? ""} ${identityContent.county ?? ""}`.trim();
cipher.identity.address1 = this.getValueOrDefault(identityContent.organization);
cipher.identity.address2 = this.getValueOrDefault(identityContent.streetAddress);
cipher.identity.address3 = this.getValueOrDefault(address3);
cipher.identity.city = this.getValueOrDefault(identityContent.city);
cipher.identity.state = this.getValueOrDefault(identityContent.stateOrProvince);
cipher.identity.postalCode = this.getValueOrDefault(identityContent.zipOrPostalCode);
cipher.identity.country = this.getValueOrDefault(identityContent.countryOrRegion);
this.processIdentityItemUnmappedAndExtraFields(cipher, identityContent);
for (const extraField of item.data.extraFields) {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
}
break;
}
default:
continue;
}
this.processFolder(result, vault.name);
this.cleanupCipher(cipher);
result.ciphers.push(cipher);
}

View File

@@ -36,8 +36,11 @@ export type ProtonPassItemData = {
metadata: ProtonPassItemMetadata;
extraFields: ProtonPassItemExtraField[];
platformSpecific?: any;
type: "login" | "alias" | "creditCard" | "note";
content: ProtonPassLoginItemContent | ProtonPassCreditCardItemContent;
type: "login" | "alias" | "creditCard" | "note" | "identity";
content:
| ProtonPassLoginItemContent
| ProtonPassCreditCardItemContent
| ProtonPassIdentityItemContent;
};
export type ProtonPassItemMetadata = {
@@ -74,3 +77,48 @@ export type ProtonPassCreditCardItemContent = {
expirationDate?: string;
pin?: string;
};
export type ProtonPassIdentityItemExtraSection = {
sectionName?: string;
sectionFields?: ProtonPassItemExtraField[];
};
export type ProtonPassIdentityItemContent = {
fullName?: string;
email?: string;
phoneNumber?: string;
firstName?: string;
middleName?: string;
lastName?: string;
birthdate?: string;
gender?: string;
extraPersonalDetails?: ProtonPassItemExtraField[];
organization?: string;
streetAddress?: string;
zipOrPostalCode?: string;
city?: string;
stateOrProvince?: string;
countryOrRegion?: string;
floor?: string;
county?: string;
extraAddressDetails?: ProtonPassItemExtraField[];
socialSecurityNumber?: string;
passportNumber?: string;
licenseNumber?: string;
website?: string;
xHandle?: string;
secondPhoneNumber?: string;
linkedin?: string;
reddit?: string;
facebook?: string;
yahoo?: string;
instagram?: string;
extraContactDetails?: ProtonPassItemExtraField[];
company?: string;
jobTitle?: string;
personalWebsite?: string;
workPhoneNumber?: string;
workEmail?: string;
extraWorkDetails?: ProtonPassItemExtraField[];
extraSections?: ProtonPassIdentityItemExtraSection[];
};

View File

@@ -3,20 +3,20 @@ import { svgIcon } from "@bitwarden/components";
export const NoCredentialsIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="119" height="114" viewBox="0 0 119 114" fill="none">
<g clip-path="url(#clip0_201_7924)">
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M35.2098 52.2486C35.9068 52.2486 36.4719 52.8137 36.4719 53.5107V58.2685C36.4719 58.9655 35.9068 59.5306 35.2098 59.5306C34.5128 59.5306 33.9478 58.9655 33.9478 58.2685V53.5107C33.9478 52.8137 34.5128 52.2486 35.2098 52.2486Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M40.9963 56.4125C41.2091 57.0762 40.8437 57.7868 40.18 57.9997L35.5951 59.4703C34.9314 59.6832 34.2208 59.3177 34.0079 58.654C33.795 57.9903 34.1605 57.2797 34.8242 57.0668L39.409 55.5962C40.0727 55.3833 40.7834 55.7487 40.9963 56.4125Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M34.471 57.2455C35.036 56.8374 35.8249 56.9647 36.233 57.5297L39.0445 61.4225C39.4526 61.9876 39.3254 62.7765 38.7603 63.1846C38.1952 63.5927 37.4063 63.4654 36.9982 62.9004L34.1868 59.0076C33.7787 58.4425 33.9059 57.6536 34.471 57.2455Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M35.94 57.2401C36.508 57.6441 36.6411 58.432 36.2371 59.0001L33.4689 62.8928C33.065 63.4609 32.277 63.5939 31.709 63.19C31.141 62.786 31.0079 61.9981 31.4119 61.43L34.1801 57.5373C34.584 56.9692 35.3719 56.8362 35.94 57.2401Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M29.4665 56.4091C29.6812 55.746 30.3929 55.3825 31.056 55.5972L35.5976 57.0679C36.2607 57.2826 36.6242 57.9942 36.4095 58.6573C36.1947 59.3205 35.4831 59.684 34.82 59.4692L30.2784 57.9986C29.6153 57.7839 29.2518 57.0723 29.4665 56.4091Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M50.6932 52.2487C51.3902 52.2487 51.9553 52.8137 51.9553 53.5107V58.2686C51.9553 58.9656 51.3902 59.5306 50.6932 59.5306C49.9962 59.5306 49.4312 58.9656 49.4312 58.2686V53.5107C49.4312 52.8137 49.9962 52.2487 50.6932 52.2487Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M56.4353 56.4088C56.6501 57.072 56.2866 57.7836 55.6234 57.9983L51.0819 59.4689C50.4187 59.6837 49.7071 59.3202 49.4924 58.657C49.2777 57.9939 49.6412 57.2823 50.3043 57.0676L54.8458 55.5969C55.509 55.3822 56.2206 55.7457 56.4353 56.4088Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M49.9544 57.2452C50.5194 56.8371 51.3083 56.9643 51.7164 57.5294L54.5279 61.4221C54.936 61.9872 54.8087 62.7761 54.2437 63.1842C53.6786 63.5923 52.8897 63.4651 52.4816 62.9L49.6702 59.0072C49.2621 58.4422 49.3893 57.6533 49.9544 57.2452Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M51.4331 57.2452C51.9982 57.6533 52.1254 58.4422 51.7173 59.0072L48.9059 62.9C48.4978 63.4651 47.7089 63.5923 47.1438 63.1842C46.5788 62.7761 46.4515 61.9872 46.8596 61.4221L49.6711 57.5294C50.0792 56.9643 50.8681 56.8371 51.4331 57.2452Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M44.9514 56.4088C45.1661 55.7457 45.8777 55.3822 46.5409 55.5969L51.0824 57.0676C51.7455 57.2823 52.109 57.9939 51.8943 58.657C51.6796 59.3202 50.968 59.6837 50.3048 59.4689L45.7633 57.9983C45.1001 57.7836 44.7366 57.072 44.9514 56.4088Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M60.5229 62.3772C60.5229 61.6802 61.088 61.1151 61.785 61.1151H70.7935C71.4905 61.1151 72.0556 61.6802 72.0556 62.3772C72.0556 63.0742 71.4905 63.6392 70.7935 63.6392H61.785C61.088 63.6392 60.5229 63.0742 60.5229 62.3772Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M75.9663 62.3772C75.9663 61.6802 76.5314 61.1151 77.2284 61.1151H86.2369C86.9339 61.1151 87.4989 61.6802 87.4989 62.3772C87.4989 63.0742 86.9339 63.6392 86.2369 63.6392H77.2284C76.5314 63.6392 75.9663 63.0742 75.9663 62.3772Z" />
<path fill="#15C0CB" fill-rule="evenodd" clip-rule="evenodd" d="M20.1396 57.9313C20.1396 50.6126 26.0726 44.6796 33.3914 44.6796H86.3982C93.7169 44.6796 99.6499 50.6126 99.6499 57.9313C99.6499 65.25 93.7169 71.183 86.3982 71.183H33.3914C26.0726 71.183 20.1396 65.25 20.1396 57.9313ZM33.3914 47.2037C27.4667 47.2037 22.6638 52.0066 22.6638 57.9313C22.6638 63.856 27.4667 68.6589 33.3914 68.6589H86.3982C92.3229 68.6589 97.1258 63.856 97.1258 57.9313C97.1258 52.0066 92.3229 47.2037 86.3982 47.2037H33.3914Z"/>
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M40.8279 11.8469C41.4764 12.1023 41.7952 12.835 41.5398 13.4836L37.3784 24.0525C37.123 24.701 36.3902 25.0198 35.7417 24.7644C35.0931 24.509 34.7744 23.7762 35.0298 23.1277L38.0204 15.5323C35.2016 16.9889 32.4865 18.7508 29.92 20.8232C9.44808 37.3546 6.25361 67.3517 22.785 87.8236C27.3496 93.4763 32.9382 97.8098 39.0683 100.775C39.6957 101.079 39.9583 101.834 39.6547 102.461C39.3512 103.089 38.5964 103.351 37.969 103.048C31.5107 99.9231 25.6247 95.3579 20.8212 89.4094C3.414 67.8529 6.77771 36.2666 28.3342 18.8594C31.1318 16.6003 34.0994 14.6905 37.1838 13.1248L29.3343 10.0341C28.6857 9.77875 28.367 9.04598 28.6223 8.39742C28.8777 7.74886 29.6105 7.43012 30.259 7.68548L40.8279 11.8469ZM84.1129 15.392C84.4739 14.7958 85.2499 14.6051 85.8462 14.9661C90.6935 17.901 95.1212 21.7125 98.8842 26.3725C116.291 47.929 112.928 79.5153 91.3711 96.9224C90.3117 97.7779 89.2278 98.5834 88.1224 99.339L96.3064 101.382C96.9827 101.551 97.394 102.236 97.2252 102.912C97.0564 103.588 96.3713 104 95.6951 103.831L84.6746 101.08C83.9984 100.911 83.587 100.226 83.7558 99.5498L86.5067 88.5294C86.6755 87.8531 87.3606 87.4417 88.0368 87.6105C88.7131 87.7794 89.1245 88.4644 88.9557 89.1407L86.9784 97.0621C87.9316 96.4005 88.8679 95.6994 89.7853 94.9586C110.257 78.4273 113.452 48.4302 96.9203 27.9583C93.3439 23.5293 89.1393 19.9108 84.5388 17.1253C83.9426 16.7643 83.7519 15.9883 84.1129 15.392Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M35.2098 52.2486C35.9068 52.2486 36.4719 52.8137 36.4719 53.5107V58.2685C36.4719 58.9655 35.9068 59.5306 35.2098 59.5306C34.5128 59.5306 33.9478 58.9655 33.9478 58.2685V53.5107C33.9478 52.8137 34.5128 52.2486 35.2098 52.2486Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M40.9963 56.4125C41.2091 57.0762 40.8437 57.7868 40.18 57.9997L35.5951 59.4703C34.9314 59.6832 34.2208 59.3177 34.0079 58.654C33.795 57.9903 34.1605 57.2797 34.8242 57.0668L39.409 55.5962C40.0727 55.3833 40.7834 55.7487 40.9963 56.4125Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M34.471 57.2455C35.036 56.8374 35.8249 56.9647 36.233 57.5297L39.0445 61.4225C39.4526 61.9876 39.3254 62.7765 38.7603 63.1846C38.1952 63.5927 37.4063 63.4654 36.9982 62.9004L34.1868 59.0076C33.7787 58.4425 33.9059 57.6536 34.471 57.2455Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M35.94 57.2401C36.508 57.6441 36.6411 58.432 36.2371 59.0001L33.4689 62.8928C33.065 63.4609 32.277 63.5939 31.709 63.19C31.141 62.786 31.0079 61.9981 31.4119 61.43L34.1801 57.5373C34.584 56.9692 35.3719 56.8362 35.94 57.2401Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M29.4665 56.4091C29.6812 55.746 30.3929 55.3825 31.056 55.5972L35.5976 57.0679C36.2607 57.2826 36.6242 57.9942 36.4095 58.6573C36.1947 59.3205 35.4831 59.684 34.82 59.4692L30.2784 57.9986C29.6153 57.7839 29.2518 57.0723 29.4665 56.4091Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M50.6932 52.2487C51.3902 52.2487 51.9553 52.8137 51.9553 53.5107V58.2686C51.9553 58.9656 51.3902 59.5306 50.6932 59.5306C49.9962 59.5306 49.4312 58.9656 49.4312 58.2686V53.5107C49.4312 52.8137 49.9962 52.2487 50.6932 52.2487Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M56.4353 56.4088C56.6501 57.072 56.2866 57.7836 55.6234 57.9983L51.0819 59.4689C50.4187 59.6837 49.7071 59.3202 49.4924 58.657C49.2777 57.9939 49.6412 57.2823 50.3043 57.0676L54.8458 55.5969C55.509 55.3822 56.2206 55.7457 56.4353 56.4088Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M49.9544 57.2452C50.5194 56.8371 51.3083 56.9643 51.7164 57.5294L54.5279 61.4221C54.936 61.9872 54.8087 62.7761 54.2437 63.1842C53.6786 63.5923 52.8897 63.4651 52.4816 62.9L49.6702 59.0072C49.2621 58.4422 49.3893 57.6533 49.9544 57.2452Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M51.4331 57.2452C51.9982 57.6533 52.1254 58.4422 51.7173 59.0072L48.9059 62.9C48.4978 63.4651 47.7089 63.5923 47.1438 63.1842C46.5788 62.7761 46.4515 61.9872 46.8596 61.4221L49.6711 57.5294C50.0792 56.9643 50.8681 56.8371 51.4331 57.2452Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M44.9514 56.4088C45.1661 55.7457 45.8777 55.3822 46.5409 55.5969L51.0824 57.0676C51.7455 57.2823 52.109 57.9939 51.8943 58.657C51.6796 59.3202 50.968 59.6837 50.3048 59.4689L45.7633 57.9983C45.1001 57.7836 44.7366 57.072 44.9514 56.4088Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M60.5229 62.3772C60.5229 61.6802 61.088 61.1151 61.785 61.1151H70.7935C71.4905 61.1151 72.0556 61.6802 72.0556 62.3772C72.0556 63.0742 71.4905 63.6392 70.7935 63.6392H61.785C61.088 63.6392 60.5229 63.0742 60.5229 62.3772Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M75.9663 62.3772C75.9663 61.6802 76.5314 61.1151 77.2284 61.1151H86.2369C86.9339 61.1151 87.4989 61.6802 87.4989 62.3772C87.4989 63.0742 86.9339 63.6392 86.2369 63.6392H77.2284C76.5314 63.6392 75.9663 63.0742 75.9663 62.3772Z" />
<path class="tw-fill-art-accent" fill-rule="evenodd" clip-rule="evenodd" d="M20.1396 57.9313C20.1396 50.6126 26.0726 44.6796 33.3914 44.6796H86.3982C93.7169 44.6796 99.6499 50.6126 99.6499 57.9313C99.6499 65.25 93.7169 71.183 86.3982 71.183H33.3914C26.0726 71.183 20.1396 65.25 20.1396 57.9313ZM33.3914 47.2037C27.4667 47.2037 22.6638 52.0066 22.6638 57.9313C22.6638 63.856 27.4667 68.6589 33.3914 68.6589H86.3982C92.3229 68.6589 97.1258 63.856 97.1258 57.9313C97.1258 52.0066 92.3229 47.2037 86.3982 47.2037H33.3914Z"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M40.8279 11.8469C41.4764 12.1023 41.7952 12.835 41.5398 13.4836L37.3784 24.0525C37.123 24.701 36.3902 25.0198 35.7417 24.7644C35.0931 24.509 34.7744 23.7762 35.0298 23.1277L38.0204 15.5323C35.2016 16.9889 32.4865 18.7508 29.92 20.8232C9.44808 37.3546 6.25361 67.3517 22.785 87.8236C27.3496 93.4763 32.9382 97.8098 39.0683 100.775C39.6957 101.079 39.9583 101.834 39.6547 102.461C39.3512 103.089 38.5964 103.351 37.969 103.048C31.5107 99.9231 25.6247 95.3579 20.8212 89.4094C3.414 67.8529 6.77771 36.2666 28.3342 18.8594C31.1318 16.6003 34.0994 14.6905 37.1838 13.1248L29.3343 10.0341C28.6857 9.77875 28.367 9.04598 28.6223 8.39742C28.8777 7.74886 29.6105 7.43012 30.259 7.68548L40.8279 11.8469ZM84.1129 15.392C84.4739 14.7958 85.2499 14.6051 85.8462 14.9661C90.6935 17.901 95.1212 21.7125 98.8842 26.3725C116.291 47.929 112.928 79.5153 91.3711 96.9224C90.3117 97.7779 89.2278 98.5834 88.1224 99.339L96.3064 101.382C96.9827 101.551 97.394 102.236 97.2252 102.912C97.0564 103.588 96.3713 104 95.6951 103.831L84.6746 101.08C83.9984 100.911 83.587 100.226 83.7558 99.5498L86.5067 88.5294C86.6755 87.8531 87.3606 87.4417 88.0368 87.6105C88.7131 87.7794 89.1245 88.4644 88.9557 89.1407L86.9784 97.0621C87.9316 96.4005 88.8679 95.6994 89.7853 94.9586C110.257 78.4273 113.452 48.4302 96.9203 27.9583C93.3439 23.5293 89.1393 19.9108 84.5388 17.1253C83.9426 16.7643 83.7519 15.9883 84.1129 15.392Z" />
</g>
<defs>
<clipPath id="clip0_201_7924">

View File

@@ -62,6 +62,8 @@ export class BaseSendDetailsComponent implements OnInit {
} as SendView);
});
});
this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm);
}
async ngOnInit() {

View File

@@ -16,6 +16,13 @@
[sendDetailsForm]="sendDetailsForm"
></tools-send-text-details>
<tools-send-file-details
*ngIf="config.sendType === FileSendType"
[config]="config"
[originalSendView]="originalSendView"
[sendDetailsForm]="sendDetailsForm"
></tools-send-file-details>
<bit-form-field>
<bit-label>{{ "deletionDate" | i18n }}</bit-label>
<bit-select

View File

@@ -19,6 +19,7 @@ import {
import { SendFormContainer } from "../../send-form-container";
import { BaseSendDetailsComponent } from "./base-send-details.component";
import { SendFileDetailsComponent } from "./send-file-details.component";
import { SendTextDetailsComponent } from "./send-text-details.component";
@Component({
@@ -34,6 +35,7 @@ import { SendTextDetailsComponent } from "./send-text-details.component";
FormFieldModule,
ReactiveFormsModule,
SendTextDetailsComponent,
SendFileDetailsComponent,
IconButtonModule,
CheckboxModule,
CommonModule,

View File

@@ -0,0 +1,30 @@
<bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config.mode === 'edit'">
<div class="tw-text-muted">{{ "file" | i18n }}</div>
<div>{{ originalSendView.file.fileName }}</div>
<div class="tw-text-muted">{{ originalSendView.file.sizeName }}</div>
</div>
<bit-form-field *ngIf="config.mode !== 'edit'">
<bit-label for="file">{{ "fileToShare" | i18n }}</bit-label>
<button bitButton type="button" buttonType="primary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
<span
class="tw-flex tw-items-center tw-pl-3"
[ngClass]="fileName ? 'tw-text-main' : 'tw-text-muted'"
>
{{ fileName || ("noFileChosen" | i18n) }}</span
>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
hidden
(change)="onFileSelected($event)"
/>
<bit-hint>
{{ "maxFileSize" | i18n }}
</bit-hint>
</bit-form-field>
</bit-section>

View File

@@ -0,0 +1,92 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
FormBuilder,
FormControl,
FormGroup,
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 { ButtonModule, FormFieldModule, SectionComponent } from "@bitwarden/components";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container";
import { BaseSendDetailsForm } from "./base-send-details.component";
type BaseSendFileDetailsForm = FormGroup<{
file: FormControl<SendFileView | null>;
}>;
export type SendFileDetailsForm = BaseSendFileDetailsForm & BaseSendDetailsForm;
@Component({
selector: "tools-send-file-details",
templateUrl: "./send-file-details.component.html",
standalone: true,
imports: [
ButtonModule,
CommonModule,
JslibModule,
ReactiveFormsModule,
FormFieldModule,
SectionComponent,
FormsModule,
],
})
export class SendFileDetailsComponent implements OnInit {
@Input() config: SendFormConfig;
@Input() originalSendView?: SendView;
@Input() sendDetailsForm: BaseSendDetailsForm;
baseSendFileDetailsForm: BaseSendFileDetailsForm;
sendFileDetailsForm: SendFileDetailsForm;
FileSendType = SendType.File;
fileName = "";
constructor(
private formBuilder: FormBuilder,
protected sendFormContainer: SendFormContainer,
) {
this.baseSendFileDetailsForm = this.formBuilder.group({
file: this.formBuilder.control<SendFileView | null>(null, Validators.required),
});
this.sendFileDetailsForm = Object.assign(this.baseSendFileDetailsForm, this.sendDetailsForm);
this.sendFormContainer.registerChildForm("sendFileDetailsForm", this.sendFileDetailsForm);
this.sendFileDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
return Object.assign(send, {
file: value.file,
});
});
});
}
onFileSelected = (event: Event): void => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
this.fileName = file.name;
this.sendFormContainer.onFileSelected(file);
};
ngOnInit() {
if (this.originalSendView) {
this.sendFileDetailsForm.patchValue({
file: this.originalSendView.file,
});
}
}
}

View File

@@ -65,6 +65,7 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
private bitSubmit: BitSubmitDirective;
private destroyRef = inject(DestroyRef);
private _firstInitialized = false;
private file: File | null = null;
/**
* The form ID to use for the form. Used to connect it to a submit button.
@@ -188,14 +189,17 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
private i18nService: I18nService,
) {}
onFileSelected(file: File): void {
this.file = file;
}
submit = async () => {
if (this.sendForm.invalid) {
this.sendForm.markAllAsTouched();
return;
}
// TODO: Add file handling
await this.addEditFormService.saveSend(this.updatedSendView, null, this.config);
await this.addEditFormService.saveSend(this.updatedSendView, this.file, this.config);
this.toastService.showToast({
variant: "success",

View File

@@ -2,6 +2,7 @@ import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendFormConfig } from "./abstractions/send-form-config.service";
import { SendDetailsComponent } from "./components/send-details/send-details.component";
import { SendFileDetailsForm } from "./components/send-details/send-file-details.component";
import { SendTextDetailsForm } from "./components/send-details/send-text-details.component";
/**
* The complete form for a send. Includes all the sub-forms from their respective section components.
@@ -10,6 +11,7 @@ import { SendTextDetailsForm } from "./components/send-details/send-text-details
export type SendForm = {
sendDetailsForm?: SendDetailsComponent["sendDetailsForm"];
sendTextDetailsForm?: SendTextDetailsForm;
sendFileDetailsForm?: SendFileDetailsForm;
};
/**
@@ -37,5 +39,7 @@ export abstract class SendFormContainer {
group: Exclude<SendForm[K], undefined>,
): void;
abstract onFileSelected(file: File): void;
abstract patchSend(updateFn: (current: SendView) => SendView): void;
}

View File

@@ -1,17 +0,0 @@
import { Controls, Meta, Primary } from "@storybook/addon-docs";
import * as stories from "./send-form.stories";
<Meta of={stories} />
# Send Form
The send form is a re-usable form component that can be used to create, update, and clone sends. It
is configured via a `SendFormConfig` object that is passed to the component as a prop. The
`SendFormConfig` object can be created manually, or a `SendFormConfigService` can be used to create
it. A default implementation of the `SendFormConfigService` exists in the `@bitwarden/send-ui`
library.
<Primary />
<Controls include={["config", "submitBtn"]} />

View File

@@ -1,134 +0,0 @@
import { importProvidersFrom } from "@angular/core";
import { action } from "@storybook/addon-actions";
import {
applicationConfig,
componentWrapperDecorator,
Meta,
moduleMetadata,
StoryObj,
} from "@storybook/angular";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components";
import { SendFormConfig } from "@bitwarden/send-ui";
// FIXME: remove `/apps` import from `/libs`
// eslint-disable-next-line import/no-restricted-paths
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
import { SendFormService } from "./abstractions/send-form.service";
import { SendFormComponent } from "./components/send-form.component";
import { SendFormModule } from "./send-form.module";
const defaultConfig: SendFormConfig = {
mode: "add",
sendType: SendType.Text,
areSendsAllowed: true,
originalSend: {
id: "123",
name: "Test Send",
notes: "Example notes",
} as unknown as Send,
};
class TestAddEditFormService implements SendFormService {
decryptSend(): Promise<SendView> {
return Promise.resolve(defaultConfig.originalSend as any);
}
async saveSend(send: SendView, file: File | ArrayBuffer): Promise<SendView> {
await new Promise((resolve) => setTimeout(resolve, 1000));
return send;
}
}
const actionsData = {
onSave: action("onSave"),
};
export default {
title: "Tools/Send Form",
component: SendFormComponent,
decorators: [
moduleMetadata({
imports: [SendFormModule, AsyncActionsModule, ButtonModule],
providers: [
{
provide: SendFormService,
useClass: TestAddEditFormService,
},
{
provide: ToastService,
useValue: {
showToast: action("showToast"),
},
},
],
}),
componentWrapperDecorator(
(story) => `<div class="tw-bg-background-alt tw-text-main tw-border">${story}</div>`,
),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
],
args: {
config: defaultConfig,
},
argTypes: {
config: {
description: "The configuration object for the form.",
},
},
} as Meta;
type Story = StoryObj<SendFormComponent>;
export const Default: Story = {
render: (args) => {
return {
props: {
onSave: actionsData.onSave,
...args,
},
template: /*html*/ `
<tools-send-form [config]="config" (cipherSaved)="onSave($event)" formId="test-form" [submitBtn]="submitBtn"></tools-send-form>
<button type="submit" form="test-form" bitButton buttonType="primary" #submitBtn>Submit</button>
`,
};
},
};
export const Edit: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "edit",
originalSend: defaultConfig.originalSend,
},
},
};
export const PartialEdit: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "partial-edit",
originalSend: defaultConfig.originalSend,
},
},
};
export const SendsHaveBeenDisabledByPolicy: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "add",
areSendsAllowed: false,
originalSend: defaultConfig.originalSend,
},
},
};

View File

@@ -85,6 +85,9 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
/** Emits after a file has been successfully uploaded */
@Output() onUploadSuccess = new EventEmitter<void>();
/** Emits after a file has been successfully removed */
@Output() onRemoveSuccess = new EventEmitter<void>();
cipher: CipherView;
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
@@ -216,5 +219,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
if (index > -1) {
this.cipher.attachments.splice(index, 1);
}
this.onRemoveSuccess.emit();
}
}

View File

@@ -7,6 +7,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -39,6 +40,8 @@ describe("LoginDetailsSectionComponent", () => {
let toastService: MockProxy<ToastService>;
let totpCaptureService: MockProxy<TotpCaptureService>;
let i18nService: MockProxy<I18nService>;
let configService: MockProxy<ConfigService>;
const collect = jest.fn().mockResolvedValue(null);
beforeEach(async () => {
@@ -49,6 +52,7 @@ describe("LoginDetailsSectionComponent", () => {
toastService = mock<ToastService>();
totpCaptureService = mock<TotpCaptureService>();
i18nService = mock<I18nService>();
configService = mock<ConfigService>();
collect.mockClear();
await TestBed.configureTestingModule({
@@ -60,6 +64,7 @@ describe("LoginDetailsSectionComponent", () => {
{ provide: ToastService, useValue: toastService },
{ provide: TotpCaptureService, useValue: totpCaptureService },
{ provide: I18nService, useValue: i18nService },
{ provide: ConfigService, useValue: configService },
{ provide: EventCollectionService, useValue: { collect } },
],
})

View File

@@ -0,0 +1,22 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle>
{{ title }}
</span>
<ng-container bitDialogContent>
<vault-cipher-form-generator
[type]="params.type"
(valueGenerated)="onValueGenerated($event)"
></vault-cipher-form-generator>
</ng-container>
<ng-container bitDialogFooter>
<button
type="button"
bitButton
buttonType="primary"
(click)="selectValue()"
data-testid="select-button"
>
{{ selectButtonText }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,125 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction";
import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component";
import {
WebVaultGeneratorDialogComponent,
WebVaultGeneratorDialogParams,
WebVaultGeneratorDialogAction,
} from "./web-generator-dialog.component";
describe("WebVaultGeneratorDialogComponent", () => {
let component: WebVaultGeneratorDialogComponent;
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
let dialogRef: MockProxy<DialogRef<any>>;
let mockI18nService: MockProxy<I18nService>;
let passwordOptionsSubject: BehaviorSubject<any>;
let usernameOptionsSubject: BehaviorSubject<any>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
beforeEach(async () => {
dialogRef = mock<DialogRef<any>>();
mockI18nService = mock<I18nService>();
passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]);
usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]);
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPasswordGenerationService.getOptions$.mockReturnValue(
passwordOptionsSubject.asObservable(),
);
mockUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
mockUsernameGenerationService.getOptions$.mockReturnValue(
usernameOptionsSubject.asObservable(),
);
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
providers: [
{
provide: DialogRef,
useValue: dialogRef,
},
{
provide: DIALOG_DATA,
useValue: mockDialogData,
},
{
provide: I18nService,
useValue: mockI18nService,
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockPasswordGenerationService,
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mockUsernameGenerationService,
},
{
provide: CipherFormGeneratorComponent,
useValue: {
passwordOptions$: passwordOptionsSubject.asObservable(),
usernameOptions$: usernameOptionsSubject.asObservable(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("initializes without errors", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("closes the dialog with 'canceled' result when close is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
(component as any).close();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Canceled,
});
});
it("closes the dialog with 'selected' result when selectValue is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
const generatedValue = "generated-value";
component.onValueGenerated(generatedValue);
(component as any).selectValue();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: generatedValue,
});
});
it("updates generatedValue when onValueGenerated is called", () => {
const generatedValue = "new-generated-value";
component.onValueGenerated(generatedValue);
expect((component as any).generatedValue).toBe(generatedValue);
});
});

View File

@@ -0,0 +1,89 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogService } from "@bitwarden/components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { DialogModule } from "../../../../../../libs/components/src/dialog";
export interface WebVaultGeneratorDialogParams {
type: "password" | "username";
}
export interface WebVaultGeneratorDialogResult {
action: WebVaultGeneratorDialogAction;
generatedValue?: string;
}
export enum WebVaultGeneratorDialogAction {
Selected = "selected",
Canceled = "canceled",
}
@Component({
selector: "web-vault-generator-dialog",
templateUrl: "./web-generator-dialog.component.html",
standalone: true,
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule],
})
export class WebVaultGeneratorDialogComponent {
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator");
protected selectButtonText = this.i18nService.t(
this.isPassword ? "useThisPassword" : "useThisUsername",
);
/**
* Whether the dialog is generating a password/passphrase. If false, it is generating a username.
* @protected
*/
protected get isPassword() {
return this.params.type === "password";
}
/**
* The currently generated value.
* @protected
*/
protected generatedValue: string = "";
constructor(
@Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams,
private dialogRef: DialogRef<WebVaultGeneratorDialogResult>,
private i18nService: I18nService,
) {}
/**
* Close the dialog without selecting a value.
*/
protected close = () => {
this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled });
};
/**
* Close the dialog and select the currently generated value.
*/
protected selectValue = () => {
this.dialogRef.close({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: this.generatedValue,
});
};
onValueGenerated(value: string) {
this.generatedValue = value;
}
/**
* Opens the vault generator dialog.
*/
static open(dialogService: DialogService, config: DialogConfig<WebVaultGeneratorDialogParams>) {
return dialogService.open<WebVaultGeneratorDialogResult, WebVaultGeneratorDialogParams>(
WebVaultGeneratorDialogComponent,
{
...config,
},
);
}
}

View File

@@ -0,0 +1,88 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service";
describe("WebCipherFormGenerationService", () => {
let service: WebCipherFormGenerationService;
let dialogService: jest.Mocked<DialogService>;
let closed = of({});
const close = jest.fn();
const dialogRef = {
close,
get closed() {
return closed;
},
} as unknown as DialogRef<unknown, unknown>;
beforeEach(() => {
dialogService = mock<DialogService>();
TestBed.configureTestingModule({
providers: [
WebCipherFormGenerationService,
{ provide: DialogService, useValue: dialogService },
],
});
service = TestBed.inject(WebCipherFormGenerationService);
});
it("creates without error", () => {
expect(service).toBeTruthy();
});
describe("generatePassword", () => {
it("opens the password generator dialog and returns the generated value", async () => {
const generatedValue = "generated-password";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "password" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(result).toBeNull();
});
});
describe("generateUsername", () => {
it("opens the username generator dialog and returns the generated value", async () => {
const generatedValue = "generated-username";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "username" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,40 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { CipherFormGenerationService } from "@bitwarden/vault";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
@Injectable()
export class WebCipherFormGenerationService implements CipherFormGenerationService {
private dialogService = inject(DialogService);
async generatePassword(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "password" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
async generateUsername(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "username" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
}

View File

@@ -97,7 +97,7 @@
bitBadge
variant="success"
class="tw-ml-2 tw-cursor-pointer"
(click)="getPremium()"
(click)="getPremium(cipher.organizationId)"
slot="end"
>
{{ "premium" | i18n }}

View File

@@ -1,6 +1,5 @@
import { CommonModule, DatePipe } from "@angular/common";
import { Component, inject, Input } from "@angular/core";
import { Router } from "@angular/router";
import { Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -20,6 +19,7 @@ import {
ColorPasswordModule,
} from "@bitwarden/components";
import { PremiumUpgradePromptService } from "../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { BitTotpCountdownComponent } from "../../components/totp-countdown/totp-countdown.component";
import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component";
@@ -61,8 +61,8 @@ export class LoginCredentialsViewComponent {
constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService,
private router: Router,
private i18nService: I18nService,
private premiumUpgradeService: PremiumUpgradePromptService,
private eventCollectionService: EventCollectionService,
) {}
@@ -75,8 +75,8 @@ export class LoginCredentialsViewComponent {
return `${dateCreated} ${creationDate}`;
}
async getPremium() {
await this.router.navigate(["/premium"]);
async getPremium(organizationId?: string) {
await this.premiumUpgradeService.promptForPremium(organizationId);
}
async pwToggleValue(passwordVisible: boolean) {

8
package-lock.json generated
View File

@@ -129,7 +129,7 @@
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"electron": "32.0.2",
"electron": "32.1.1",
"electron-builder": "24.13.3",
"electron-log": "5.0.1",
"electron-reload": "2.0.0-alpha.1",
@@ -16414,9 +16414,9 @@
}
},
"node_modules/electron": {
"version": "32.0.2",
"resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz",
"integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==",
"version": "32.1.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-32.1.1.tgz",
"integrity": "sha512-NlWvG6kXOJbZbELmzP3oV7u50I3NHYbCeh+AkUQ9vGyP7b74cFMx9HdTzejODeztW1jhr3SjIBbUZzZ45zflfQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",

View File

@@ -91,7 +91,7 @@
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"electron": "32.0.2",
"electron": "32.1.1",
"electron-builder": "24.13.3",
"electron-log": "5.0.1",
"electron-reload": "2.0.0-alpha.1",