1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

Merge branch 'main' of github.com:bitwarden/clients into feature/PM-30737-Migrate-DeleteAccount

This commit is contained in:
Isaac Ivins
2026-02-03 11:21:24 -05:00
41 changed files with 637 additions and 94 deletions

6
.github/CODEOWNERS vendored
View File

@@ -15,6 +15,10 @@ apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-managemen
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml
# Web connectors
apps/web/src/connectors @bitwarden/team-auth-dev
apps/web/src/connectors/platform @bitwarden/team-platform-dev
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev
apps/cli/src/auth @bitwarden/team-auth-dev
@@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev
apps/web/src/app/auth @bitwarden/team-auth-dev
libs/auth @bitwarden/team-auth-dev
libs/user-core @bitwarden/team-auth-dev
# web connectors used for auth
apps/web/src/connectors @bitwarden/team-auth-dev
bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev
libs/angular/src/auth @bitwarden/team-auth-dev
libs/common/src/auth @bitwarden/team-auth-dev

View File

@@ -63,6 +63,11 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
steps:
- name: Log inputs to job summary
uses: bitwarden/ios/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@@ -181,6 +186,19 @@ jobs:
ref: ${{ steps.set-server-ref.outputs.server_ref }}
persist-credentials: false
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: sdk-internal
if_no_artifact_found: fail
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc-web"

View File

@@ -1727,7 +1727,7 @@ describe("NotificationBackground", () => {
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
});
it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => {
it("and no cipher update candidates match `password` or `newPassword`, trigger a new cipher notification", async () => {
const storedCiphersForURL = [
mock<CipherView>({
id: "cipher-id-1",
@@ -1745,7 +1745,15 @@ describe("NotificationBackground", () => {
await notificationBackground.triggerCipherNotification(formEntryData, tab);
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith(
mockFormattedURI,
{
password: formEntryData.newPassword,
url: formEntryData.uri,
username: formEntryData.username,
},
sender.tab,
);
});
});

View File

@@ -992,6 +992,7 @@ export default class NotificationBackground {
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
inputScenarios.username,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
newLoginNotificationIsEnabled

View File

@@ -110,7 +110,7 @@
<bit-item>
<a bit-item-content routerLink="/download-bitwarden">
<i slot="start" class="bwi bwi-mobile" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<div class="tw-flex tw-items-center">
<p class="tw-pr-2">{{ "downloadBitwardenOnAllDevices" | i18n }}</p>
<span
*ngIf="showDownloadBitwardenNudge$ | async"

View File

@@ -97,7 +97,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
async ngOnChanges(changes: SimpleChanges) {
if (changes.cipher) {
if (changes.cipher || changes.action) {
await this.checkArchiveState();
}
}
@@ -255,12 +255,15 @@ export class ItemFooterComponent implements OnInit, OnChanges {
this.userCanArchive = userCanArchive;
this.showArchiveButton =
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
cipherCanBeArchived &&
userCanArchive &&
(this.action === "view" || this.action === "edit") &&
!this.cipher.isArchived;
// A user should always be able to unarchive an archived item
this.showUnarchiveButton =
hasArchiveFlagEnabled &&
this.action === "view" &&
(this.action === "view" || this.action === "edit") &&
this.cipher.isArchived &&
!this.cipher.isDeleted;
}

View File

@@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \
rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \
fi
# Override SDK if custom artifacts are present
RUN if [ -d "sdk-internal" ]; then \
echo "Overriding SDK with custom artifacts from sdk-internal" ; \
npm link ./sdk-internal ; \
fi
WORKDIR /source/apps/web
ARG NPM_COMMAND=dist:bit:selfhost
RUN npm run ${NPM_COMMAND}

View File

@@ -2,7 +2,7 @@
<bit-dialog [disablePadding]="!loading" dialogSize="large">
<span bitDialogTitle>
<ng-container *ngIf="editMode">
{{ "editCollection" | i18n }}
{{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
collection.name
}}</span>

View File

@@ -1,7 +1,9 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BitwardenSubscription } from "@bitwarden/subscription";
import {
@@ -53,4 +55,27 @@ export class AccountBillingClient {
const path = `${this.endpoint}/subscription/storage`;
await this.apiService.send("PUT", path, { additionalStorageGb }, true, false);
};
upgradePremiumToOrganization = async (
organizationName: string,
organizationKey: string,
planTier: ProductTierType,
cadence: SubscriptionCadence,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> => {
const path = `${this.endpoint}/upgrade`;
await this.apiService.send(
"POST",
path,
{
organizationName,
key: organizationKey,
targetProductTierType: planTier,
cadence,
billingAddress,
},
true,
false,
);
};
}

View File

@@ -1,4 +1,4 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";
export * from "./tax.client";
export * from "./preview-invoice.client";
export * from "./account-billing.client";

View File

@@ -1,6 +1,7 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
@@ -16,6 +17,24 @@ class TaxAmountResponse extends BaseResponse implements TaxAmounts {
}
}
export class ProrationPreviewResponse extends BaseResponse {
tax: number;
total: number;
credit: number;
newPlanProratedMonths: number;
newPlanProratedAmount: number;
constructor(response: any) {
super(response);
this.tax = this.getResponseProperty("Tax");
this.total = this.getResponseProperty("Total");
this.credit = this.getResponseProperty("Credit");
this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths");
this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount");
}
}
export type OrganizationSubscriptionPlan = {
tier: "families" | "teams" | "enterprise";
cadence: "annually" | "monthly";
@@ -51,7 +70,7 @@ export interface TaxAmounts {
}
@Injectable()
export class TaxClient {
export class PreviewInvoiceClient {
constructor(private apiService: ApiService) {}
previewTaxForOrganizationSubscriptionPurchase = async (
@@ -60,7 +79,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
"/billing/tax/organizations/subscriptions/purchase",
"/billing/preview-invoice/organizations/subscriptions/purchase",
{
purchase,
billingAddress,
@@ -82,7 +101,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/plan-change`,
`/billing/preview-invoice/organizations/${organizationId}/subscription/plan-change`,
{
plan,
billingAddress,
@@ -100,7 +119,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/update`,
`/billing/preview-invoice/organizations/${organizationId}/subscription/update`,
{
update,
},
@@ -117,7 +136,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/premium/subscriptions/purchase`,
`/billing/preview-invoice/premium/subscriptions/purchase`,
{
additionalStorage,
billingAddress,
@@ -128,4 +147,22 @@ export class TaxClient {
return new TaxAmountResponse(json);
};
previewProrationForPremiumUpgrade = async (
planTier: ProductTierType,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<ProrationPreviewResponse> => {
const prorationResponse = await this.apiService.send(
"POST",
`/billing/preview-invoice/premium/subscriptions/upgrade`,
{
targetProductTierType: planTier,
billingAddress,
},
true,
true,
);
return new ProrationPreviewResponse(prorationResponse);
};
}

View File

@@ -15,7 +15,7 @@ import {
DialogService,
} from "@bitwarden/components";
import { AccountBillingClient, TaxClient } from "../../../clients";
import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
@@ -74,7 +74,7 @@ export type UnifiedUpgradeDialogParams = {
UpgradePaymentComponent,
BillingServicesModule,
],
providers: [UpgradePaymentService, AccountBillingClient, TaxClient],
providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient],
templateUrl: "./unified-upgrade-dialog.component.html",
})
export class UnifiedUpgradeDialogComponent implements OnInit {

View File

@@ -21,7 +21,7 @@ import {
AccountBillingClient,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
PreviewInvoiceClient,
} from "../../../../clients";
import {
BillingAddress,
@@ -35,7 +35,7 @@ import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
describe("UpgradePaymentService", () => {
const mockOrganizationBillingService = mock<OrganizationBillingServiceAbstraction>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockTaxClient = mock<TaxClient>();
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
const mockLogService = mock<LogService>();
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
@@ -112,7 +112,7 @@ describe("UpgradePaymentService", () => {
beforeEach(() => {
mockReset(mockOrganizationBillingService);
mockReset(mockAccountBillingClient);
mockReset(mockTaxClient);
mockReset(mockPreviewInvoiceClient);
mockReset(mockLogService);
mockReset(mockOrganizationService);
mockReset(mockAccountService);
@@ -133,7 +133,7 @@ describe("UpgradePaymentService", () => {
useValue: mockOrganizationBillingService,
},
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: TaxClient, useValue: mockTaxClient },
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
{ provide: LogService, useValue: mockLogService },
{ provide: SyncService, useValue: mockSyncService },
{ provide: OrganizationService, useValue: mockOrganizationService },
@@ -183,7 +183,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -236,7 +236,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -271,7 +271,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -307,7 +307,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -333,7 +333,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -389,7 +389,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -412,17 +412,18 @@ describe("UpgradePaymentService", () => {
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 2.5;
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse);
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(
mockResponse,
);
// Act
const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(2.5);
expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith(
0,
mockBillingAddress,
);
expect(
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase,
).toHaveBeenCalledWith(0, mockBillingAddress);
});
it("should calculate tax for families plan", async () => {
@@ -430,14 +431,18 @@ describe("UpgradePaymentService", () => {
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 5.0;
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse);
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(
mockResponse,
);
// Act
const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(5.0);
expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith(
expect(
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase,
).toHaveBeenCalledWith(
{
cadence: "annually",
tier: "families",
@@ -454,7 +459,7 @@ describe("UpgradePaymentService", () => {
it("should throw and log error if personal tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
@@ -466,7 +471,9 @@ describe("UpgradePaymentService", () => {
it("should throw and log error if organization tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error);
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(
error,
);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress),

View File

@@ -26,7 +26,7 @@ import {
OrganizationSubscriptionPurchase,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
PreviewInvoiceClient,
} from "../../../../clients";
import {
BillingAddress,
@@ -58,7 +58,7 @@ export class UpgradePaymentService {
constructor(
private organizationBillingService: OrganizationBillingServiceAbstraction,
private accountBillingClient: AccountBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private logService: LogService,
private syncService: SyncService,
private organizationService: OrganizationService,
@@ -101,7 +101,7 @@ export class UpgradePaymentService {
const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
let taxClientCall: Promise<TaxAmounts> | null = null;
let previewInvoiceClientCall: Promise<TaxAmounts> | null = null;
if (isFamiliesPlan) {
// Currently, only Families plan is supported for organization plans
@@ -111,22 +111,26 @@ export class UpgradePaymentService {
passwordManager: { seats: 1, additionalStorage: 0, sponsored: false },
};
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
request,
previewInvoiceClientCall =
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
request,
billingAddress,
);
}
if (isPremiumPlan) {
previewInvoiceClientCall = this.previewInvoiceClient.previewTaxForPremiumSubscriptionPurchase(
0,
billingAddress,
);
}
if (isPremiumPlan) {
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
}
if (taxClientCall === null) {
throw new Error("Tax client call is not defined");
if (previewInvoiceClientCall === null) {
throw new Error("Preview client call is not defined");
}
try {
const preview = await taxClientCall;
const preview = await previewInvoiceClientCall;
return preview.tax;
} catch (error) {
this.logService.error("Tax calculation failed:", error);

View File

@@ -50,7 +50,7 @@ import { KeyService } from "@bitwarden/key-management";
import {
OrganizationSubscriptionPlan,
SubscriberBillingClient,
TaxClient,
PreviewInvoiceClient,
} from "@bitwarden/web-vault/app/billing/clients";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import {
@@ -117,7 +117,7 @@ interface OnSuccessArgs {
EnterBillingAddressComponent,
CardComponent,
],
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -248,7 +248,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private organizationWarningsService: OrganizationWarningsService,
private configService: ConfigService,
) {}
@@ -1068,11 +1068,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress)
: this.billingAddress;
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organizationId,
getPlanFromLegacyEnum(this.selectedPlan.type),
billingAddress,
);
const taxAmounts =
await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organizationId,
getPlanFromLegacyEnum(this.selectedPlan.type),
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}

View File

@@ -52,8 +52,8 @@ import { KeyService } from "@bitwarden/key-management";
import {
OrganizationSubscriptionPlan,
OrganizationSubscriptionPurchase,
PreviewInvoiceClient,
SubscriberBillingClient,
TaxClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
@@ -87,7 +87,7 @@ const Allowed2020PlansForLegacyProviders = [
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class OrganizationPlansComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -219,7 +219,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private configService: ConfigService,
) {
this.selfHosted = this.platformUtilsService.isSelfHost();
@@ -793,11 +793,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// by comparing tax on base+storage vs tax on base only
//TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585
const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(0, false),
billingAddress,
),
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false),
billingAddress,
),
@@ -806,10 +806,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// Tax on storage = Tax on (base + storage) - Tax on (base only)
this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax;
} else {
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview),
billingAddress,
);
const taxAmounts =
await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(
this.formGroup.value.additionalStorage,
sponsoredForTaxPreview,
),
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}

View File

@@ -34,7 +34,10 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
SubscriberBillingClient,
PreviewInvoiceClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
@@ -73,7 +76,7 @@ interface OnSuccessArgs {
selector: "app-trial-payment-dialog",
templateUrl: "./trial-payment-dialog.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -118,7 +121,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
) {
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
}
@@ -300,7 +303,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
const tier = getTierFromLegacyEnum(this.organization);
if (tier && cadence) {
const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
const costs = await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organization.id,
{
tier,

View File

@@ -15,7 +15,7 @@ import {
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
EnterBillingAddressComponent,
@@ -41,7 +41,7 @@ export interface OrganizationCreatedEvent {
selector: "app-trial-billing-step",
templateUrl: "./trial-billing-step.component.html",
imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule],
providers: [TaxClient, TrialBillingStepService],
providers: [PreviewInvoiceClient, TrialBillingStepService],
})
export class TrialBillingStepComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals

View File

@@ -12,7 +12,7 @@ import {
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
getBillingAddressFromControls,
@@ -63,7 +63,7 @@ export class TrialBillingStepService {
private accountService: AccountService,
private apiService: ApiService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private configService: ConfigService,
) {}
@@ -129,7 +129,7 @@ export class TrialBillingStepService {
total: number;
}> => {
const billingAddress = getBillingAddressFromControls(billingAddressControls);
return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
return await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
{
tier,
cadence,

View File

@@ -54,7 +54,7 @@
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
<th bitCell class="tw-text-right" bitSortable="scoreKey" default="desc">
{{ "weakness" | i18n }}
</th>
</ng-container>

View File

@@ -1,6 +1,6 @@
<bit-dialog dialogSize="large" disablePadding="false" background="alt">
<ng-container bitDialogTitle>
<span>{{ dialogTitle() | i18n }}</span>
<span>{{ dialogTitle | i18n }}</span>
</ng-container>
<ng-container bitDialogContent>
<div
@@ -17,7 +17,13 @@
</h3>
<p bitTypography="body1" class="tw-mb-6 tw-max-w-sm">
{{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }}
@let translationKey =
send.authType === AuthType.Email
? "sendCreatedDescriptionEmail"
: send.authType === AuthType.Password
? "sendCreatedDescriptionPassword"
: "sendCreatedDescriptionV2";
{{ translationKey | i18n: formattedExpirationTime }}
</p>
<bit-form-field class="tw-w-full tw-max-w-sm tw-mb-4">

View File

@@ -0,0 +1,162 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
DIALOG_DATA,
DialogModule,
I18nMockService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component";
describe("SendSuccessDrawerDialogComponent", () => {
let fixture: ComponentFixture<SendSuccessDrawerDialogComponent>;
let component: SendSuccessDrawerDialogComponent;
let environmentService: MockProxy<EnvironmentService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let toastService: MockProxy<ToastService>;
let sendView: SendView;
// Translation Keys
const newTextSend = "New Text Send";
const newFileSend = "New File Send";
const oneHour = "1 hour";
const oneDay = "1 day";
const sendCreatedSuccessfully = "Send has been created successfully";
const sendCreatedDescriptionV2 = "Send ready to share with anyone";
const sendCreatedDescriptionEmail = "Email-verified Send ready to share";
const sendCreatedDescriptionPassword = "Password-protected Send ready to share";
beforeEach(async () => {
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();
toastService = mock<ToastService>();
sendView = {
id: "test-send-id",
authType: AuthType.None,
deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
type: SendType.Text,
accessId: "abc",
urlB64Key: "123",
} as SendView;
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
});
await TestBed.configureTestingModule({
imports: [SharedModule, DialogModule, TypographyModule],
providers: [
{
provide: DIALOG_DATA,
useValue: sendView,
},
{ provide: EnvironmentService, useValue: environmentService },
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
newTextSend,
newFileSend,
sendCreatedSuccessfully,
sendCreatedDescriptionEmail,
sendCreatedDescriptionPassword,
sendCreatedDescriptionV2,
sendLink: "Send link",
copyLink: "Copy Send Link",
close: "Close",
oneHour,
durationTimeHours: (hours) => `${hours} hours`,
oneDay,
days: (days) => `${days} days`,
loading: "loading",
});
},
},
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should have the correct title for text Sends", () => {
sendView.type = SendType.Text;
fixture.detectChanges();
expect(component.dialogTitle).toBe("newTextSend");
});
it("should have the correct title for file Sends", () => {
fixture.componentInstance.send.type = SendType.File;
fixture.detectChanges();
expect(component.dialogTitle).toBe("newFileSend");
});
it("should show the correct message for Sends with an expiration time of one hour from now", () => {
sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(oneHour);
});
it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => {
const numHours = 8;
sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(`${numHours} hours`);
});
it("should have the correct title for Sends with an expiration time of one day from now", () => {
sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(oneDay);
});
it("should have the correct title for Sends with an expiration time of multiple days from now", () => {
const numDays = 3;
sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(`${numDays} days`);
});
it("should show the correct message for successfully-created Sends with no authentication", () => {
sendView.authType = AuthType.None;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2);
});
it("should show the correct message for successfully-created Sends with password authentication", () => {
sendView.authType = AuthType.Password;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword);
});
it("should show the correct message for successfully-created Sends with email authentication", () => {
sendView.authType = AuthType.Email;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail);
});
});

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core";
import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ActiveSendIcon } from "@bitwarden/assets/svg";
@@ -6,6 +6,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendSuccessDrawerDialogComponent {
readonly AuthType = AuthType;
readonly sendLink = signal<string>("");
activeSendIcon = ActiveSendIcon;
// Computed property to get the dialog title based on send type
readonly dialogTitle = computed(() => {
get dialogTitle(): string {
return this.send.type === SendType.Text ? "newTextSend" : "newFileSend";
});
}
constructor(
@Inject(DIALOG_DATA) public send: SendView,

View File

@@ -593,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsWereSentToArchive"),
message: this.i18nService.t("itemWasSentToArchive"),
});
} catch {
this.toastService.showToast({

View File

@@ -157,7 +157,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
if (!this.archiveEnabled()) {
if (!this.archiveEnabled() || this.viewingOrgVault) {
return false;
}

View File

@@ -4,7 +4,7 @@
[disabled]="disabled"
[style.color]="textColor"
[style.background-color]="color"
appA11yTitle="{{ organizationName }}"
appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}"
routerLink
[queryParams]="{ organizationId: organizationIdLink }"
queryParamsHandling="merge"

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html class="theme_light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1010" />
<meta name="theme-color" content="#175DDC" />
<title>Bitwarden Web vault</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../images/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="../../images/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../images/icons/favicon-16x16.png" />
<link rel="mask-icon" href="../../images/icons/safari-pinned-tab.svg" color="#175DDC" />
<link rel="manifest" href="../../manifest.json" />
</head>
<body class="layout_frontend">
<div class="tw-p-8 tw-flex">
<img class="new-logo-themed" alt="Bitwarden" />
<div class="spinner-container tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
title="Loading"
aria-hidden="true"
></i>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
/**
* ONLY FOR SELF-HOSTED SETUPS
* Redirects the user to the SSO cookie vendor endpoint when the window finishes loading.
*
* This script listens for the window's load event and automatically redirects the browser
* to the `api/sso-cookie-vendor` path on the current origin. This is used as part
* of an authentication flow where cookies need to be set or validated through a vendor endpoint.
*/
window.addEventListener("DOMContentLoaded", () => {
const origin = window.location.origin;
let apiURL = `${window.location.origin}/api/sso-cookie-vendor`;
// Override for local testing
if (origin.startsWith("https://localhost")) {
apiURL = "http://localhost:4000/sso-cookie-vendor";
}
window.location.href = apiURL;
});

View File

@@ -3805,6 +3805,9 @@
"editCollection": {
"message": "Edit collection"
},
"viewCollection": {
"message": "View collection"
},
"collectionInfo": {
"message": "Collection info"
},
@@ -5672,6 +5675,26 @@
}
}
},
"sendCreatedDescriptionPassword": {
"message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "7 days, 1 hour, 1 day"
}
}
},
"sendCreatedDescriptionEmail": {
"message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "7 days, 1 hour, 1 day"
}
}
},
"durationTimeHours": {
"message": "$HOURS$ hours",
"placeholders": {
@@ -12742,6 +12765,15 @@
"whenYouRemoveStorage": {
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
},
"ownerBadgeA11yDescription":{
"message": "Owner, $OWNER$, show all items owned by $OWNER$",
"placeholders":{
"owner": {
"content": "$1",
"example": "My Org Name"
}
}
},
"youHavePremium": {
"message": "You have Premium"
},

View File

@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"],
"include": ["src/connectors/*.ts"]
"include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"]
}

View File

@@ -4,5 +4,10 @@
"strictTemplates": true
},
"files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"],
"include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"]
"include": [
"src/connectors/*.ts",
"src/connectors/platform/*.ts",
"src/**/*.stories.ts",
"src/**/*.spec.ts"
]
}

View File

@@ -166,6 +166,11 @@ module.exports.buildConfig = function buildConfig(params) {
filename: "duo-redirect-connector.html",
chunks: ["connectors/duo-redirect", "styles"],
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"),
filename: "proxy-cookie-redirect-connector.html",
chunks: ["connectors/platform/proxy-cookie-redirect", "styles"],
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src/404.html"),
filename: "404.html",
@@ -403,6 +408,10 @@ module.exports.buildConfig = function buildConfig(params) {
"connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"),
"connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"),
"connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"),
"connectors/platform/proxy-cookie-redirect": path.resolve(
__dirname,
"src/connectors/platform/proxy-cookie-redirect.ts",
),
styles: [
path.resolve(__dirname, "src/scss/styles.scss"),
path.resolve(__dirname, "src/scss/tailwind.css"),

View File

@@ -9,5 +9,5 @@
"../../bitwarden_license/bit-web/src/main.ts"
],
"include": ["../../apps/web/src/connectors/*.ts"]
"include": ["../../apps/web/src/connectors/*.ts", "../../apps/web/src/connectors/platform/*.ts"]
}

View File

@@ -11,6 +11,7 @@
],
"include": [
"../../apps/web/src/connectors/*.ts",
"../../apps/web/src/connectors/platform/*.ts",
"../../apps/web/src/**/*.stories.ts",
"../../apps/web/src/**/*.spec.ts",
"src/**/*.stories.ts",

View File

@@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => {
expect(result.collections[0].name).toBe("collection1/collection2");
expect(result.collections[1].name).toBe("collection1");
});
it("should parse archived items correctly", async () => {
const archivedDate = "2025-01-15T10:30:00.000Z";
const data =
`name,type,archivedDate,login_uri,login_username,login_password` +
`\nArchived Login,login,${archivedDate},https://example.com,user,pass`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
const cipher = result.ciphers[0];
expect(cipher.name).toBe("Archived Login");
expect(cipher.archivedDate).toBeDefined();
expect(cipher.archivedDate.toISOString()).toBe(archivedDate);
});
it("should handle missing archivedDate gracefully", async () => {
const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
expect(result.ciphers[0].archivedDate).toBeUndefined();
});
});

View File

@@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer {
cipher.reprompt = CipherRepromptType.None;
}
if (!this.isNullOrWhitespace(value.archivedDate)) {
try {
cipher.archivedDate = new Date(value.archivedDate);
} catch (e) {
// eslint-disable-next-line
console.error("Unable to parse archivedDate value", e);
}
}
if (!this.isNullOrWhitespace(value.fields)) {
const fields = this.splitNewLine(value.fields);
for (let i = 0; i < fields.length; i++) {

View File

@@ -0,0 +1,87 @@
import { ButtercupCsvImporter } from "./buttercup-csv-importer";
import {
buttercupCsvTestData,
buttercupCsvWithCustomFieldsTestData,
buttercupCsvWithNoteTestData,
buttercupCsvWithSubfoldersTestData,
buttercupCsvWithUrlFieldTestData,
} from "./spec-data/buttercup-csv/testdata.csv";
describe("Buttercup CSV Importer", () => {
let importer: ButtercupCsvImporter;
beforeEach(() => {
importer = new ButtercupCsvImporter();
});
describe("given basic login data", () => {
it("should parse login data when provided valid CSV", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(2);
const cipher = result.ciphers[0];
expect(cipher.name).toEqual("Test Entry");
expect(cipher.login.username).toEqual("testuser");
expect(cipher.login.password).toEqual("testpass123");
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://example.com");
});
it("should assign entries to folders based on group_name", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.folders.length).toBe(1);
expect(result.folders[0].name).toEqual("General");
expect(result.folderRelationships.length).toBe(2);
});
});
describe("given URL field variations", () => {
it("should handle lowercase url field", async () => {
const result = await importer.parse(buttercupCsvWithUrlFieldTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com");
});
});
describe("given note field", () => {
it("should map note field to notes", async () => {
const result = await importer.parse(buttercupCsvWithNoteTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.notes).toEqual("This is a note");
});
});
describe("given custom fields", () => {
it("should import custom fields and exclude official props", async () => {
const result = await importer.parse(buttercupCsvWithCustomFieldsTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.fields.length).toBe(2);
expect(cipher.fields[0].name).toEqual("custom_field");
expect(cipher.fields[0].value).toEqual("custom value");
expect(cipher.fields[1].name).toEqual("another_field");
expect(cipher.fields[1].value).toEqual("another value");
});
});
describe("given subfolders", () => {
it("should create nested folder structure", async () => {
const result = await importer.parse(buttercupCsvWithSubfoldersTestData);
expect(result.success).toBe(true);
const folderNames = result.folders.map((f) => f.name);
expect(folderNames).toContain("Work/Projects");
expect(folderNames).toContain("Work");
expect(folderNames).toContain("Personal/Finance");
expect(folderNames).toContain("Personal");
});
});
});

View File

@@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result";
import { BaseImporter } from "./base-importer";
import { Importer } from "./importer";
const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"];
const OfficialProps = [
"!group_id",
"!group_name",
"!type",
"title",
"username",
"password",
"URL",
"url",
"note",
"id",
];
export class ButtercupCsvImporter extends BaseImporter implements Importer {
parse(data: string): Promise<ImportResult> {
@@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer {
cipher.name = this.getValueOrDefault(value.title, "--");
cipher.login.username = this.getValueOrDefault(value.username);
cipher.login.password = this.getValueOrDefault(value.password);
cipher.login.uris = this.makeUriArray(value.URL);
let processingCustomFields = false;
// Handle URL field (case-insensitive)
const urlValue = value.URL || value.url || value.Url;
cipher.login.uris = this.makeUriArray(urlValue);
// Handle note field (case-insensitive)
const noteValue = value.note || value.Note || value.notes || value.Notes;
if (noteValue) {
cipher.notes = noteValue;
}
// Process custom fields, excluding official props (case-insensitive)
for (const prop in value) {
// eslint-disable-next-line
if (value.hasOwnProperty(prop)) {
if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) {
processingCustomFields = true;
}
if (processingCustomFields) {
const lowerProp = prop.toLowerCase();
const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp);
if (!isOfficialProp && value[prop]) {
this.processKvp(cipher, prop, value[prop]);
}
}

View File

@@ -0,0 +1,16 @@
export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id
1,General,Test Entry,testuser,testpass123,https://example.com,entry1
1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`;
export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id
1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`;
export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id
1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`;
export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id
1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`;
export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id
1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1
2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`;

View File

@@ -59,6 +59,7 @@ export class BaseVaultExportService {
cipher.notes = c.notes;
cipher.fields = null;
cipher.reprompt = c.reprompt;
cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null;
// Login props
cipher.login_uri = null;
cipher.login_username = null;

View File

@@ -12,6 +12,7 @@ export type BitwardenCsvExportType = {
login_password: string;
login_totp: string;
favorite: number | null;
archivedDate: string | null;
};
export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & {