1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-16 08:34:39 +00:00

Merge branch 'main' of github.com:bitwarden/clients into PM-25525-DEBT-Fix-SystemServiceProvider-dependency-injection

pull latest from main to troubleshoot github action error
This commit is contained in:
John Harrington
2025-10-07 08:58:14 -07:00
663 changed files with 35031 additions and 14536 deletions

View File

@@ -0,0 +1,20 @@
<div class="tw-flex tw-flex-col tw-items-center">
<ng-container *ngIf="currentState === 'assert'">
<p bitTypography="body1" class="tw-text-center">{{ "readingPasskeyLoading" | i18n }}</p>
<button type="button" bitButton block [loading]="true" buttonType="primary" class="tw-mb-4">
{{ "loading" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="currentState === 'assertFailed'">
<p bitTypography="body1" class="tw-text-center">{{ "passkeyAuthenticationFailed" | i18n }}</p>
<button type="button" bitButton block buttonType="primary" class="tw-mb-4" (click)="retry()">
{{ "tryAgain" | i18n }}
</button>
</ng-container>
<p bitTypography="body1" class="tw-mb-0 tw-text-center">
{{ "troubleLoggingIn" | i18n }}<br />
<a bitLink routerLink="/login">{{ "useADifferentLogInMethod" | i18n }}</a>
</p>
</div>

View File

@@ -1,27 +1,69 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
TwoFactorAuthSecurityKeyIcon,
TwoFactorAuthSecurityKeyFailedIcon,
} from "@bitwarden/assets/svg";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { ClientType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
AnonLayoutWrapperDataService,
ButtonModule,
IconModule,
LinkModule,
TypographyModule,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
export type State = "assert" | "assertFailed";
@Directive()
export class BaseLoginViaWebAuthnComponent implements OnInit {
@Component({
selector: "app-login-via-webauthn",
templateUrl: "login-via-webauthn.component.html",
standalone: true,
imports: [
CommonModule,
RouterModule,
JslibModule,
ButtonModule,
IconModule,
LinkModule,
TypographyModule,
],
})
export class LoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
protected successRoute = "/vault";
protected readonly Icons = {
TwoFactorAuthSecurityKeyIcon,
TwoFactorAuthSecurityKeyFailedIcon,
};
private readonly successRoutes: Record<ClientType, string> = {
[ClientType.Web]: "/vault",
[ClientType.Browser]: "/tabs/vault",
[ClientType.Desktop]: "/vault",
[ClientType.Cli]: "/vault",
};
protected get successRoute(): string {
const clientType = this.platformUtilsService.getClientType();
return this.successRoutes[clientType] || "/vault";
}
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
@@ -31,6 +73,8 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
private i18nService: I18nService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
) {}
ngOnInit(): void {
@@ -41,6 +85,8 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
protected retry() {
this.currentState = "assert";
// Reset to default icon on retry
this.setDefaultIcon();
// 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.authenticate();
@@ -54,6 +100,7 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
} catch (error) {
this.validationService.showError(error);
this.currentState = "assertFailed";
this.setFailureIcon();
return;
}
try {
@@ -64,6 +111,7 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
this.i18nService.t("twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn"),
);
this.currentState = "assertFailed";
this.setFailureIcon();
return;
}
@@ -80,6 +128,19 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
}
this.logService.error(error);
this.currentState = "assertFailed";
this.setFailureIcon();
}
}
private setDefaultIcon(): void {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: this.Icons.TwoFactorAuthSecurityKeyIcon,
});
}
private setFailureIcon(): void {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: this.Icons.TwoFactorAuthSecurityKeyFailedIcon,
});
}
}

View File

@@ -1,55 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>{{ "payPal" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button [value]="paymentMethodType.BitPay">
<bit-label> <i class="bwi bwi-bitcoin"></i>{{ "bitcoin" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<div class="tw-grid tw-grid-cols-2">
<bit-form-field>
<bit-label>{{ "amount" | i18n }}</bit-label>
<input bitInput type="number" formControlName="creditAmount" step="0.01" required />
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #payPalForm action="{{ payPalConfig.buttonAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ payPalConfig.businessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ payPalConfig.customField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ payPalConfig.subject }}" />
</form>

View File

@@ -1,167 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService, AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
export type AddAccountCreditDialogParams = {
organizationId?: string;
providerId?: string;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AddAccountCreditDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openAddAccountCreditDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<AddAccountCreditDialogParams>,
) =>
dialogService.open<AddAccountCreditDialogResultType, AddAccountCreditDialogParams>(
AddAccountCreditDialogComponent,
dialogConfig,
);
type PayPalConfig = {
businessId?: string;
buttonAction?: string;
returnUrl?: string;
customField?: string;
subject?: string;
};
@Component({
templateUrl: "./add-account-credit-dialog.component.html",
standalone: false,
})
export class AddAccountCreditDialogComponent implements OnInit {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef;
protected formGroup = new FormGroup({
paymentMethod: new FormControl<PaymentMethodType>(PaymentMethodType.PayPal),
creditAmount: new FormControl<number>(null, [Validators.required, Validators.min(0.01)]),
});
protected payPalConfig: PayPalConfig;
protected ResultType = AddAccountCreditDialogResultType;
private organization?: Organization;
private provider?: Provider;
private user?: { id: UserId } & AccountInfo;
constructor(
private accountService: AccountService,
private apiService: ApiService,
private configService: ConfigService,
@Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams,
private dialogRef: DialogRef<AddAccountCreditDialogResultType>,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private providerService: ProviderService,
) {
this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
}
protected readonly paymentMethodType = PaymentMethodType;
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) {
this.payPalForm.nativeElement.submit();
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) {
const request = this.getBitPayInvoiceRequest();
const bitPayUrl = await this.apiService.postBitPayInvoice(request);
this.platformUtilsService.launchUri(bitPayUrl);
return;
}
this.dialogRef.close(AddAccountCreditDialogResultType.Submitted);
};
async ngOnInit(): Promise<void> {
let payPalCustomField: string;
if (this.dialogParams.organizationId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.user = await firstValueFrom(this.accountService.activeAccount$);
this.organization = await firstValueFrom(
this.organizationService
.organizations$(this.user.id)
.pipe(
map((organizations) =>
organizations.find((org) => org.id === this.dialogParams.organizationId),
),
),
);
payPalCustomField = "organization_id:" + this.organization.id;
this.payPalConfig.subject = this.organization.name;
} else if (this.dialogParams.providerId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.provider = await firstValueFrom(
this.providerService.get$(this.dialogParams.providerId, this.user.id),
);
payPalCustomField = "provider_id:" + this.provider.id;
this.payPalConfig.subject = this.provider.name;
} else {
this.formGroup.patchValue({
creditAmount: 10.0,
});
payPalCustomField = "user_id:" + this.user.id;
this.payPalConfig.subject = this.user.email;
}
const region = await firstValueFrom(this.configService.cloudRegion$);
payPalCustomField += ",account_credit:1";
payPalCustomField += `,region:${region}`;
this.payPalConfig.customField = payPalCustomField;
this.payPalConfig.returnUrl = window.location.href;
}
getBitPayInvoiceRequest(): BitPayInvoiceRequest {
const request = new BitPayInvoiceRequest();
if (this.organization) {
request.name = this.organization.name;
request.organizationId = this.organization.id;
} else if (this.provider) {
request.name = this.provider.name;
request.providerId = this.provider.id;
} else {
request.email = this.user.email;
request.userId = this.user.id;
}
request.credit = true;
request.amount = this.formGroup.value.creditAmount;
request.returnUrl = window.location.href;
return request;
}
}

View File

@@ -1,5 +1 @@
export * from "./add-account-credit-dialog/add-account-credit-dialog.component";
export * from "./invoices/invoices.component";
export * from "./invoices/no-invoices.component";
export * from "./manage-tax-information/manage-tax-information.component";
export * from "./premium.component";

View File

@@ -1,60 +0,0 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-table *ngIf="!loading">
<ng-container header>
<tr>
<th bitCell>{{ "date" | i18n }}</th>
<th bitCell>{{ "invoice" | i18n }}</th>
<th bitCell>{{ "total" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "clientDetails" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let invoice of invoices">
<td bitCell>{{ invoice.date | date: "mediumDate" }}</td>
<td bitCell>
<a
href="{{ invoice.url }}"
target="_blank"
rel="noreferrer"
title="{{ 'viewInvoice' | i18n }}"
>
{{ invoice.number }}
</a>
</td>
<td bitCell>{{ invoice.total | currency: "$" }}</td>
<td bitCell *ngIf="expandInvoiceStatus(invoice) as expandedInvoiceStatus">
<span *ngIf="expandedInvoiceStatus === 'open'">
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'uncollectible'">
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "uncollectible" | i18n | titlecase }}
</span>
</td>
<td bitCell>
<button type="button" bitLink (click)="runExport(invoice.id)">
<span class="tw-font-normal">{{ "downloadCSV" | i18n }}</span>
</button>
</td>
</tr>
</ng-template>
</bit-table>
<div *ngIf="!invoices || invoices.length === 0" class="tw-mt-10">
<app-no-invoices></app-no-invoices>
</div>

View File

@@ -1,67 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import {
InvoiceResponse,
InvoicesResponse,
} from "@bitwarden/common/billing/models/response/invoices.response";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
@Component({
selector: "app-invoices",
templateUrl: "./invoices.component.html",
standalone: false,
})
export class InvoicesComponent implements OnInit {
@Input() startWith?: InvoicesResponse;
@Input() getInvoices?: () => Promise<InvoicesResponse>;
@Input() getClientInvoiceReport?: (invoiceId: string) => Promise<string>;
@Input() getClientInvoiceReportName?: (invoiceResponse: InvoiceResponse) => string;
protected invoices: InvoiceResponse[] = [];
protected loading = true;
constructor(private fileDownloadService: FileDownloadService) {}
runExport = async (invoiceId: string): Promise<void> => {
const blobData = await this.getClientInvoiceReport(invoiceId);
let fileName = "report.csv";
if (this.getClientInvoiceReportName) {
const invoice = this.invoices.find((invoice) => invoice.id === invoiceId);
fileName = this.getClientInvoiceReportName(invoice);
}
this.fileDownloadService.download({
fileName,
blobData,
blobOptions: {
type: "text/csv",
},
});
};
async ngOnInit(): Promise<void> {
if (this.startWith) {
this.invoices = this.startWith.invoices;
} else if (this.getInvoices) {
const response = await this.getInvoices();
this.invoices = response.invoices;
}
this.loading = false;
}
expandInvoiceStatus = (
invoice: InvoiceResponse,
): "open" | "unpaid" | "paid" | "uncollectible" => {
switch (invoice.status) {
case "open": {
const dueDate = new Date(invoice.dueDate);
return dueDate < new Date() ? "unpaid" : invoice.status;
}
case "paid":
case "uncollectible": {
return invoice.status;
}
}
};
}

View File

@@ -1,14 +0,0 @@
import { Component } from "@angular/core";
import { CreditCardIcon } from "@bitwarden/assets/svg";
@Component({
selector: "app-no-invoices",
template: `<bit-no-items [icon]="icon">
<div slot="title">{{ "noInvoicesToList" | i18n }}</div>
</bit-no-items>`,
standalone: false,
})
export class NoInvoicesComponent {
icon = CreditCardIcon;
}

View File

@@ -1,90 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country" data-testid="country">
<bit-option
*ngFor="let country of countries"
[value]="country.value"
[disabled]="country.disabled"
[label]="country.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
<ng-container *ngIf="isTaxSupported">
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="showTaxIdField">
<bit-form-field disableMargin>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
</bit-form-field>
</div>
</ng-container>
<div class="tw-col-span-12" *ngIf="!!onSubmit">
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</div>
</div>
</form>

View File

@@ -1,262 +0,0 @@
import { CommonModule } from "@angular/common";
import { SimpleChange } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ManageTaxInformationComponent } from "./manage-tax-information.component";
describe("ManageTaxInformationComponent", () => {
let sut: ManageTaxInformationComponent;
let fixture: ComponentFixture<ManageTaxInformationComponent>;
let mockTaxService: MockProxy<TaxServiceAbstraction>;
beforeEach(async () => {
mockTaxService = mock();
await TestBed.configureTestingModule({
declarations: [ManageTaxInformationComponent],
providers: [
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
imports: [
CommonModule,
ReactiveFormsModule,
SelectModule,
FormFieldModule,
BitSubmitDirective,
I18nPipe,
],
}).compileComponents();
fixture = TestBed.createComponent(ManageTaxInformationComponent);
sut = fixture.componentInstance;
fixture.autoDetectChanges();
});
afterEach(() => {
jest.clearAllMocks();
});
it("creates successfully", () => {
expect(sut).toBeTruthy();
});
it("should initialize with all values empty in startWith", async () => {
// Arrange
sut.startWith = {
country: "",
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
// Act
fixture.detectChanges();
// Assert
const startWithValue = sut.startWith;
expect(startWithValue.line1).toHaveLength(0);
expect(startWithValue.line2).toHaveLength(0);
expect(startWithValue.city).toHaveLength(0);
expect(startWithValue.state).toHaveLength(0);
expect(startWithValue.postalCode).toHaveLength(0);
expect(startWithValue.country).toHaveLength(0);
expect(startWithValue.taxId).toHaveLength(0);
});
it("should update the tax information protected state when form is updated", async () => {
// Arrange
const line1Value = "123 Street";
const line2Value = "Apt. 5";
const cityValue = "New York";
const stateValue = "NY";
const countryValue = "USA";
const postalCodeValue = "123 Street";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
line1.value = line1Value;
line2.value = line2Value;
city.value = cityValue;
state.value = stateValue;
postalCode.value = postalCodeValue;
line1.dispatchEvent(new Event("input"));
line2.dispatchEvent(new Event("input"));
city.dispatchEvent(new Event("input"));
state.dispatchEvent(new Event("input"));
postalCode.dispatchEvent(new Event("input"));
await fixture.whenStable();
// Assert
// Assert that the internal tax information reflects the form
const taxInformation = sut.getTaxInformation();
expect(taxInformation.line1).toBe(line1Value);
expect(taxInformation.line2).toBe(line2Value);
expect(taxInformation.city).toBe(cityValue);
expect(taxInformation.state).toBe(stateValue);
expect(taxInformation.postalCode).toBe(postalCodeValue);
expect(taxInformation.country).toBe(countryValue);
expect(taxInformation.taxId).toHaveLength(0);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2);
});
it("should not show address fields except postal code if country is not supported for taxes", async () => {
// Arrange
const countryValue = "UNKNOWN";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(false);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
// Assert
expect(line1).toBeNull();
expect(line2).toBeNull();
expect(city).toBeNull();
expect(state).toBeNull();
//Should be visible
expect(postalCode).toBeTruthy();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should not show the tax id field if showTaxIdField is set to false", async () => {
// Arrange
const countryValue = "USA";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
// Assert
const taxId: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
);
expect(taxId).toBeNull();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should clear the tax id field if showTaxIdField is set to false after being true", async () => {
// Arrange
const countryValue = "USA";
const taxIdValue = "A12345678";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: taxIdValue,
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = true;
mockTaxService.isCountrySupported.mockResolvedValue(true);
await sut.ngOnInit();
fixture.detectChanges();
const initialTaxIdValue = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
).value;
// Act
sut.showTaxIdField = false;
sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) });
fixture.detectChanges();
// Assert
const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']");
expect(taxId).toBeNull();
const taxInformation = sut.getTaxInformation();
expect(taxInformation.taxId).toBeNull();
expect(initialTaxIdValue).toEqual(taxIdValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,166 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/models/domain";
@Component({
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges {
@Input() startWith: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Input() showTaxIdField: boolean = true;
/**
* Emits when the tax information has changed.
*/
@Output() taxInformationChanged = new EventEmitter<TaxInformation>();
/**
* Emits when the tax information has been updated.
*/
@Output() taxInformationUpdated = new EventEmitter();
private taxInformation: TaxInformation;
protected formGroup = this.formBuilder.group({
country: ["", Validators.required],
postalCode: ["", Validators.required],
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
});
protected isTaxSupported: boolean;
private destroy$ = new Subject<void>();
protected readonly countries: CountryListItem[] = this.taxService.getCountries();
constructor(
private formBuilder: FormBuilder,
private taxService: TaxServiceAbstraction,
) {}
getTaxInformation(): TaxInformation {
return this.taxInformation;
}
submit = async () => {
this.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
await this.onSubmit?.(this.taxInformation);
this.taxInformationUpdated.emit();
};
validate(): boolean {
this.markAllAsTouched();
return this.formGroup.valid;
}
markAllAsTouched() {
this.formGroup.markAllAsTouched();
}
async ngOnInit() {
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
this.taxInformation = {
country: values.country,
postalCode: values.postalCode,
taxId: values.taxId,
line1: values.line1,
line2: values.line2,
city: values.city,
state: values.state,
};
});
if (this.startWith) {
this.formGroup.controls.country.setValue(this.startWith.country);
this.formGroup.controls.postalCode.setValue(this.startWith.postalCode);
this.isTaxSupported =
this.startWith && this.startWith.country
? await this.taxService.isCountrySupported(this.startWith.country)
: false;
if (this.isTaxSupported) {
this.formGroup.controls.taxId.setValue(this.startWith.taxId);
this.formGroup.controls.line1.setValue(this.startWith.line1);
this.formGroup.controls.line2.setValue(this.startWith.line2);
this.formGroup.controls.city.setValue(this.startWith.city);
this.formGroup.controls.state.setValue(this.startWith.state);
}
}
this.formGroup.controls.country.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe((country: string) => {
this.taxService
.isCountrySupported(country)
.then((isSupported) => (this.isTaxSupported = isSupported))
.catch(() => (this.isTaxSupported = false))
.finally(() => {
if (!this.isTaxSupported) {
this.formGroup.controls.taxId.setValue(null);
this.formGroup.controls.line1.setValue(null);
this.formGroup.controls.line2.setValue(null);
this.formGroup.controls.city.setValue(null);
this.formGroup.controls.state.setValue(null);
}
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
});
this.formGroup.controls.postalCode.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
this.formGroup.controls.taxId.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe(() => {
if (this.taxInformationChanged) {
this.taxInformationChanged.emit(this.taxInformation);
}
});
}
ngOnChanges(changes: SimpleChanges): void {
// Clear the value of the tax-id if states have been changed in the parent component
const showTaxIdField = changes["showTaxIdField"];
if (showTaxIdField && !showTaxIdField.currentValue) {
this.formGroup.controls.taxId.setValue(null);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -2,12 +2,6 @@ import { CommonModule, DatePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import {
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
} from "@bitwarden/angular/billing/components";
import {
AsyncActionsModule,
AutofocusDirective,
@@ -112,10 +106,6 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
TwoFactorIconComponent,
],
exports: [
@@ -146,10 +136,6 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
NoInvoicesComponent,
ManageTaxInformationComponent,
TwoFactorIconComponent,
TextDragDirective,
],

View File

@@ -20,11 +20,13 @@ import {
import {
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
DefaultNewDeviceVerificationComponentService,
DefaultRegistrationFinishService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
NewDeviceVerificationComponentService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
TwoFactorAuthComponentService,
TwoFactorAuthWebAuthnComponentService,
@@ -102,6 +104,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@@ -144,14 +147,12 @@ import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/a
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import {
DefaultKeyGenerationService,
@@ -264,6 +265,7 @@ import {
InternalSendService,
SendService as SendServiceAbstraction,
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
@@ -284,6 +286,7 @@ import {
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
@@ -296,7 +299,6 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
@@ -345,11 +347,7 @@ import {
import { SafeInjectionToken } from "@bitwarden/ui-common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CipherArchiveService,
DefaultCipherArchiveService,
PasswordRepromptService,
} from "@bitwarden/vault";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
@@ -1401,11 +1399,6 @@ const safeProviders: SafeProvider[] = [
useClass: BillingApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: TaxServiceAbstraction,
useClass: TaxService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: BillingAccountProfileStateService,
useClass: DefaultBillingAccountProfileStateService,
@@ -1596,6 +1589,11 @@ const safeProviders: SafeProvider[] = [
MessageListener,
],
}),
safeProvider({
provide: SendTokenService,
useClass: DefaultSendTokenService,
deps: [GlobalStateProvider, SdkService, SendPasswordService],
}),
safeProvider({
provide: EndUserNotificationService,
useClass: DefaultEndUserNotificationService,
@@ -1652,12 +1650,15 @@ const safeProviders: SafeProvider[] = [
deps: [
CipherServiceAbstraction,
ApiServiceAbstraction,
DialogService,
PasswordRepromptService,
BillingAccountProfileStateService,
ConfigService,
],
}),
safeProvider({
provide: NewDeviceVerificationComponentService,
useClass: DefaultNewDeviceVerificationComponentService,
deps: [],
}),
];
@NgModule({

View File

@@ -4,7 +4,7 @@ import { Directive, EventEmitter, Input, Output } from "@angular/core";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
@@ -21,6 +21,7 @@ export class CollectionFilterComponent {
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
new EventEmitter<ITreeNodeObject>();
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
DefaultCollectionType = CollectionTypes.DefaultUserCollection;
readonly collectionsGrouping: TopLevelTreeNode = {
id: "collections",

View File

@@ -0,0 +1,26 @@
import { svgIcon } from "../icon-service";
export const FavoritesIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="5.29 4.98 86.89 90.19">
<g clip-path="url(#clip0_2211_2391)">
<path class="tw-stroke-illustration-outline tw-fill-illustration-bg-primary" d="M45.7322 7.73645C46.8425 5.06767 50.6228 5.06767 51.7332 7.73645L60.8269 29.5929C61.511 31.2373 63.0575 32.3607 64.8328 32.5031L88.4343 34.3947C91.316 34.6257 92.4843 38.2221 90.2888 40.1027L72.3083 55.5001C70.9554 56.6589 70.3647 58.4773 70.7781 60.2101L76.2712 83.2335C76.9419 86.0452 73.8838 88.2672 71.4167 86.7609L51.2078 74.422C49.688 73.4941 47.7773 73.4941 46.2576 74.422L26.0486 86.7609C23.5815 88.2672 20.5234 86.0452 21.1941 83.2335L26.6873 60.2101C27.1007 58.4773 26.51 56.6589 25.157 55.5001L7.17651 40.1027C4.98107 38.2221 6.14942 34.6258 9.03101 34.3947L32.6326 32.5031C34.4079 32.3607 35.9543 31.2373 36.6384 29.5929L45.7322 7.73645Z" stroke-width="1.5"/>
<path class="tw-stroke-illustration-outline tw-fill-illustration-bg-tertiary" d="M86.5363 75.5456L67.0229 86.8083L64.3035 82.0996C63.2768 82.3932 60.0282 82.2577 55.7085 78.0915C51.1997 73.7429 53.1 69.4322 54.8897 67.3528L51.1991 60.9624C51.1991 60.9624 49.0085 57.1701 47.5085 54.572C46.5871 52.9761 46.8445 50.4707 48.594 49.461C50.3435 48.4512 52.9064 49.1601 53.9008 50.8825C56.3862 55.1873 60.6993 62.6543 60.6993 62.6543L60.3136 61.9865C59.0344 59.7715 57.8733 56.6618 60.0887 55.3831C60.1803 55.3302 60.2715 55.2816 60.3624 55.2371C62.6556 54.1121 64.7529 56.4687 66.0303 58.6805C65.81 57.9107 65.7395 56.1576 67.2199 55.3032C69.5661 53.949 71.7744 56.9755 73.1292 59.3214L72.6308 58.4584C72.1365 57.4731 71.6772 55.4212 73.4319 54.4084C75.2982 53.3313 76.7992 54.9314 77.3409 55.7398C78.3682 57.3892 80.6222 61.1108 81.42 62.8029C82.2178 64.495 82.7029 67.7429 82.8457 69.1553L86.5363 75.5456Z"/>
<path class="tw-stroke-illustration-outline" d="M66.0303 58.6805C65.81 57.9107 65.7395 56.1576 67.2199 55.3032V55.3032C69.5661 53.949 71.7744 56.9755 73.1292 59.3214L73.9905 60.8128L72.6308 58.4584C72.1365 57.4731 71.6772 55.4212 73.4319 54.4084C75.2982 53.3313 76.7992 54.9314 77.3409 55.7398C78.3682 57.3892 80.6222 61.1108 81.42 62.8029C82.2178 64.495 82.7029 67.7429 82.8457 69.1553L86.5363 75.5456L67.0229 86.8083L64.3035 82.0996C63.2768 82.3932 60.0282 82.2577 55.7085 78.0915C51.1997 73.7429 53.1 69.4322 54.8897 67.3528M66.0303 58.6805L67.9727 62.0439M66.0303 58.6805V58.6805C64.7529 56.4687 62.6556 54.1121 60.3624 55.2371C60.2715 55.2816 60.1803 55.3302 60.0887 55.3831V55.3831C57.8733 56.6618 59.0344 59.7715 60.3136 61.9865L60.6993 62.6543C60.6993 62.6543 56.3862 55.1873 53.9008 50.8825C52.9064 49.1601 50.3435 48.4512 48.594 49.461C46.8445 50.4707 46.5871 52.9761 47.5085 54.572C49.0085 57.1701 51.1991 60.9624 51.1991 60.9624L54.8897 67.3528M57.6091 72.0616L54.8897 67.3528" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-illustration-outline tw-fill-illustration-bg-secondary" d="M70.9905 93.6786L67.5232 87.6748C67.247 87.1965 67.4108 86.585 67.8892 86.309L85.6704 76.046C86.1487 75.7699 86.7604 75.9338 87.0366 76.4121L90.5039 82.4159C90.7802 82.8942 90.6163 83.5057 90.138 83.7818L72.3567 94.0447C71.8784 94.3208 71.2667 94.1569 70.9905 93.6786Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<g clip-path="url(#clip1_2211_2391)">
<path class="tw-stroke-illustration-tertiary" d="M41.5571 37.1704L45.0496 43.2177" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
<path class="tw-stroke-illustration-tertiary" d="M54.9774 35.4178L53.187 42.1557" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
<path class="tw-stroke-illustration-tertiary" d="M65.7288 43.6858L59.6986 47.1663" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
<path class="tw-stroke-illustration-tertiary" d="M35.1155 61.3549L41.1457 57.8744" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
<path class="tw-stroke-illustration-tertiary" d="M33.329 47.9126L40.0662 49.7286" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_2211_2391">
<rect width="96" height="96" class="tw-fill-bg-tertiary" transform="translate(0.5 0.400024)"/>
</clipPath>
<clipPath id="clip1_2211_2391">
<rect width="37.6674" height="20.0537" class="tw-fill-bg-tertiary" transform="matrix(0.86609 -0.499888 0.500112 0.86596 25.2179 45.5771)"/>
</clipPath>
</defs>
</svg>`;

View File

@@ -12,6 +12,7 @@ export * from "./deactivated-org";
export * from "./devices.icon";
export * from "./domain.icon";
export * from "./empty-trash";
export * from "./favorites.icon";
export * from "./gear";
export * from "./generator";
export * from "./item-types";

View File

@@ -59,6 +59,8 @@ export * from "./two-factor-auth";
// device verification
export * from "./new-device-verification/new-device-verification.component";
export * from "./new-device-verification/new-device-verification-component.service";
export * from "./new-device-verification/default-new-device-verification-component.service";
// validators
export * from "./validators/compare-inputs.validator";

View File

@@ -34,6 +34,7 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -138,6 +139,7 @@ export class LoginComponent implements OnInit, OnDestroy {
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
private ssoLoginService: SsoLoginServiceAbstraction,
private environmentService: EnvironmentService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -307,7 +309,7 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.handleAuthResult(authResult);
} catch (error) {
this.logService.error(error);
this.handleSubmitError(error);
await this.handleSubmitError(error);
}
};
@@ -316,15 +318,18 @@ export class LoginComponent implements OnInit, OnDestroy {
*
* @param error The error object.
*/
private handleSubmitError(error: unknown) {
private async handleSubmitError(error: unknown) {
// Handle error responses
if (error instanceof ErrorResponse) {
switch (error.statusCode) {
case HttpStatusCode.BadRequest: {
if (error.message?.toLowerCase().includes("username or password is incorrect")) {
const env = await firstValueFrom(this.environmentService.environment$);
const host = Utils.getHost(env.getWebVaultUrl());
this.formGroup.controls.masterPassword.setErrors({
error: {
message: this.i18nService.t("invalidMasterPassword"),
message: this.i18nService.t("invalidMasterPasswordConfirmEmailAndHost", host),
},
});
} else {

View File

@@ -0,0 +1,21 @@
import { DefaultNewDeviceVerificationComponentService } from "./default-new-device-verification-component.service";
describe("DefaultNewDeviceVerificationComponentService", () => {
let sut: DefaultNewDeviceVerificationComponentService;
beforeEach(() => {
sut = new DefaultNewDeviceVerificationComponentService();
});
it("should instantiate the service", () => {
expect(sut).not.toBeFalsy();
});
describe("showBackButton()", () => {
it("should return true", () => {
const result = sut.showBackButton();
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,9 @@
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
export class DefaultNewDeviceVerificationComponentService
implements NewDeviceVerificationComponentService
{
showBackButton() {
return true;
}
}

View File

@@ -0,0 +1,8 @@
export abstract class NewDeviceVerificationComponentService {
/**
* States whether component should show a back button. Can be overridden by client-specific component services.
* - Default = `true`
* - Extension = `false` (because Extension shows a back button in the header instead)
*/
abstract showBackButton: () => boolean;
}

View File

@@ -22,7 +22,7 @@
{{ "resendCode" | i18n }}
</button>
<div class="tw-flex tw-mt-4">
<div class="tw-grid tw-gap-3 tw-mt-4">
<button
bitButton
bitFormButton
@@ -33,5 +33,13 @@
>
{{ "continueLoggingIn" | i18n }}
</button>
@if (showBackButton) {
<div class="tw-text-center">{{ "or" | i18n }}</div>
<button type="button" bitButton block buttonType="secondary" (click)="goBack()">
{{ "back" | i18n }}
</button>
}
</div>
</form>

View File

@@ -1,4 +1,4 @@
import { CommonModule } from "@angular/common";
import { CommonModule, Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
@@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -26,6 +25,8 @@ import {
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
/**
* Component for verifying a new device via a one-time password (OTP).
*/
@@ -57,6 +58,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
protected disableRequestOTP = false;
private destroy$ = new Subject<void>();
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
protected showBackButton = true;
constructor(
private router: Router,
@@ -66,12 +68,15 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
private logService: LogService,
private i18nService: I18nService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
private accountService: AccountService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private newDeviceVerificationComponentService: NewDeviceVerificationComponentService,
private location: Location,
) {}
async ngOnInit() {
this.showBackButton = this.newDeviceVerificationComponentService.showBackButton();
// Redirect to timeout route if session expires
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntil(this.destroy$))
@@ -179,4 +184,8 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
codeControl.markAsTouched();
}
};
protected goBack() {
this.location.back();
}
}

View File

@@ -75,7 +75,7 @@ describe("WebAuthnLoginStrategy", () => {
// We must do this to make the mocked classes available for all the
// assertCredential(...) tests.
global.PublicKeyCredential = MockPublicKeyCredential;
global.PublicKeyCredential = MockPublicKeyCredential as any;
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
});
@@ -397,4 +397,8 @@ export class MockPublicKeyCredential implements PublicKeyCredential {
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
toJSON() {
throw new Error("Method not implemented.");
}
}

View File

@@ -77,14 +77,10 @@ import {
} from "../auth/models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
import { PaymentRequest } from "../billing/models/request/payment.request";
import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request";
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response";
import { PaymentResponse } from "../billing/models/response/payment.response";
import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
@@ -171,10 +167,8 @@ export abstract class ApiService {
abstract getProfile(): Promise<ProfileResponse>;
abstract getUserSubscription(): Promise<SubscriptionResponse>;
abstract getTaxInfo(): Promise<TaxInfoResponse>;
abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>;
abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>;
abstract putTaxInfo(request: TaxInfoUpdateRequest): Promise<any>;
abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>;
abstract postEmailToken(request: EmailTokenRequest): Promise<any>;
abstract postEmail(request: EmailRequest): Promise<any>;
@@ -185,7 +179,6 @@ export abstract class ApiService {
abstract postPremium(data: FormData): Promise<PaymentResponse>;
abstract postReinstatePremium(): Promise<any>;
abstract postAccountStorage(request: StorageRequest): Promise<PaymentResponse>;
abstract postAccountPayment(request: PaymentRequest): Promise<void>;
abstract postAccountLicense(data: FormData): Promise<any>;
abstract postAccountKeys(request: KeysRequest): Promise<any>;
abstract postAccountVerifyEmail(): Promise<any>;
@@ -209,7 +202,6 @@ export abstract class ApiService {
abstract getLastAuthRequest(): Promise<AuthRequestResponse>;
abstract getUserBillingHistory(): Promise<BillingHistoryResponse>;
abstract getUserBillingPayment(): Promise<BillingPaymentResponse>;
abstract getCipher(id: string): Promise<CipherResponse>;
abstract getFullCipherDetails(id: string): Promise<CipherResponse>;
@@ -469,6 +461,13 @@ export abstract class ApiService {
end: string,
token: string,
): Promise<ListResponse<EventResponse>>;
abstract getEventsServiceAccount(
orgId: string,
id: string,
start: string,
end: string,
token: string,
): Promise<ListResponse<EventResponse>>;
abstract getEventsProject(
orgId: string,
id: string,

View File

@@ -3,21 +3,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
import { SeatRequest } from "../../../models/request/seat.request";
import { StorageRequest } from "../../../models/request/storage.request";
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationApiKeyType } from "../../enums";
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
@@ -45,7 +41,6 @@ export abstract class OrganizationApiServiceAbstraction {
): Promise<OrganizationResponse>;
abstract createLicense(data: FormData): Promise<OrganizationResponse>;
abstract save(id: string, request: OrganizationUpdateRequest): Promise<OrganizationResponse>;
abstract updatePayment(id: string, request: PaymentRequest): Promise<void>;
abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse>;
abstract updatePasswordManagerSeats(
id: string,
@@ -57,7 +52,6 @@ export abstract class OrganizationApiServiceAbstraction {
): Promise<ProfileOrganizationResponse>;
abstract updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse>;
abstract updateStorage(id: string, request: StorageRequest): Promise<PaymentResponse>;
abstract verifyBank(id: string, request: VerifyBankRequest): Promise<void>;
abstract reinstate(id: string): Promise<void>;
abstract leave(id: string): Promise<void>;
abstract delete(id: string, request: SecretVerificationRequest): Promise<void>;
@@ -76,8 +70,6 @@ export abstract class OrganizationApiServiceAbstraction {
organizationApiKeyType?: OrganizationApiKeyType,
): Promise<ListResponse<OrganizationApiKeyInformationResponse>>;
abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise<ApiKeyResponse>;
abstract getTaxInfo(id: string): Promise<TaxInfoResponse>;
abstract updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void>;
abstract getKeys(id: string): Promise<OrganizationKeysResponse>;
abstract updateKeys(
id: string,

View File

@@ -1,7 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request";
interface TokenizedPaymentMethod {
type: "bankAccount" | "card" | "payPal";
token: string;
}
interface BillingAddress {
country: string;
postalCode: string;
line1: string | null;
line2: string | null;
city: string | null;
state: string | null;
taxId: { code: string; value: string } | null;
}
export class ProviderSetupRequest {
name: string;
@@ -9,6 +21,6 @@ export class ProviderSetupRequest {
billingEmail: string;
token: string;
key: string;
taxInfo: ExpandedTaxInfoUpdateRequest;
paymentSource?: TokenizedPaymentSourceRequest;
paymentMethod: TokenizedPaymentMethod;
billingAddress: BillingAddress;
}

View File

@@ -7,21 +7,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
import { SeatRequest } from "../../../models/request/seat.request";
import { StorageRequest } from "../../../models/request/storage.request";
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
import { ListResponse } from "../../../models/response/list.response";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
@@ -143,10 +139,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return data;
}
async updatePayment(id: string, request: PaymentRequest): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/payment", request, true, false);
}
async upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse> {
const r = await this.apiService.send(
"POST",
@@ -208,16 +200,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new PaymentResponse(r);
}
async verifyBank(id: string, request: VerifyBankRequest): Promise<void> {
await this.apiService.send(
"POST",
"/organizations/" + id + "/verify-bank",
request,
true,
false,
);
}
async reinstate(id: string): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false);
}
@@ -299,16 +281,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new ApiKeyResponse(r);
}
async getTaxInfo(id: string): Promise<TaxInfoResponse> {
const r = await this.apiService.send("GET", "/organizations/" + id + "/tax", null, true, true);
return new TaxInfoResponse(r);
}
async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void> {
// Can't broadcast anything because the response doesn't have content
return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false);
}
async getKeys(id: string): Promise<OrganizationKeysResponse> {
const r = await this.apiService.send("GET", "/organizations/" + id + "/keys", null, true, true);
return new OrganizationKeysResponse(r);

View File

@@ -0,0 +1 @@
export * from "./send-token.service";

View File

@@ -0,0 +1,57 @@
import { Observable } from "rxjs";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
/**
* Service to manage send access tokens.
*/
export abstract class SendTokenService {
/**
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
* If the access token is found in session storage and is not expired, then it returns the token.
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.
* Any submissions of credentials will be handled by the getSendAccessToken$ method.
* @param sendId The ID of the send to retrieve the access token for.
* @returns An observable that emits a SendAccessToken if successful, or a TryGetSendAccessTokenError if not.
*/
abstract tryGetSendAccessToken$: (
sendId: string,
) => Observable<SendAccessToken | TryGetSendAccessTokenError>;
/**
* Retrieves a SendAccessToken for the given sendId using the provided credentials.
* If the access token is successfully retrieved from the server, it stores the token in session storage and returns it.
* If the access token cannot be granted due to invalid credentials, it returns a {@link GetSendAccessTokenError}.
* @param sendId The ID of the send to retrieve the access token for.
* @param sendAccessCredentials The credentials to use for accessing the send.
* @returns An observable that emits a SendAccessToken if successful, or a GetSendAccessTokenError if not.
*/
abstract getSendAccessToken$: (
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
) => Observable<SendAccessToken | GetSendAccessTokenError>;
/**
* Hashes a password for send access which is required to create a {@link SendAccessTokenRequest}
* (more specifically, to create a {@link SendAccessDomainCredentials} for sends that require a password)
* @param password The raw password string to hash.
* @param keyMaterialUrlB64 The base64 URL encoded key material string.
* @returns A promise that resolves to the hashed password as a SendHashedPasswordB64.
*/
abstract hashSendPassword: (
password: string,
keyMaterialUrlB64: string,
) => Promise<SendHashedPasswordB64>;
/**
* Clears a send access token from storage.
*/
abstract invalidateSendAccessToken: (sendId: string) => Promise<void>;
}

View File

@@ -0,0 +1,4 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./send-access-token";

View File

@@ -0,0 +1,75 @@
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
import { SendAccessToken } from "./send-access-token";
describe("SendAccessToken", () => {
const sendId = "sendId";
const NOW = 1_000_000; // fixed timestamp for predictable results
const expiresAt: number = NOW + 1000 * 60 * 5; // 5 minutes from now
const expiredExpiresAt: number = NOW - 1000 * 60 * 5; // 5 minutes ago
let nowSpy: jest.SpyInstance<number, []>;
beforeAll(() => {
nowSpy = jest.spyOn(Date, "now");
});
beforeEach(() => {
// Ensure every test starts from the same fixed time
nowSpy.mockReturnValue(NOW);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("should create a valid, unexpired token", () => {
const token = new SendAccessToken(sendId, expiresAt);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
it("should be expired after the expiration time", () => {
const token = new SendAccessToken(sendId, expiredExpiresAt);
expect(token.isExpired()).toBe(true);
});
it("should be considered expired if within 5 seconds of expiration", () => {
const token = new SendAccessToken(sendId, expiresAt);
nowSpy.mockReturnValue(expiresAt - 4_000); // 4 seconds before expiry
expect(token.isExpired()).toBe(true);
});
it("should return the correct time until expiry in seconds", () => {
const token = new SendAccessToken(sendId, expiresAt);
expect(token.timeUntilExpirySeconds()).toBe(300); // 5 minutes
});
it("should return 0 if the token is expired", () => {
const token = new SendAccessToken(sendId, expiredExpiresAt);
expect(token.timeUntilExpirySeconds()).toBe(0);
});
it("should create a token from JSON", () => {
const json = {
token: sendId,
expiresAt: expiresAt,
};
const token = SendAccessToken.fromJson(json);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
it("should create a token from SendAccessTokenResponse", () => {
const response = {
token: sendId,
expiresAt: expiresAt,
} as SendAccessTokenResponse;
const token = SendAccessToken.fromSendAccessTokenResponse(response);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { Jsonify } from "type-fest";
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
export class SendAccessToken {
constructor(
/**
* The access token string
*/
readonly token: string,
/**
* The time (in milliseconds since the epoch) when the token expires
*/
readonly expiresAt: number,
) {}
/** Returns whether the send access token is expired or not
* Has a 5 second threshold to avoid race conditions with the token
* expiring in flight
*/
isExpired(threshold: number = 5_000): boolean {
return Date.now() >= this.expiresAt - threshold;
}
/** Returns how many full seconds remain until expiry. Returns 0 if expired. */
timeUntilExpirySeconds(): number {
return Math.max(0, Math.floor((this.expiresAt - Date.now()) / 1_000));
}
static fromJson(parsedJson: Jsonify<SendAccessToken>): SendAccessToken {
return new SendAccessToken(parsedJson.token, parsedJson.expiresAt);
}
/**
* Creates a SendAccessToken from a SendAccessTokenResponse.
* @param sendAccessTokenResponse The SDK response object containing the token and expiry information.
* @returns A new instance of SendAccessToken.
* note: we need to convert from the SDK response type to our internal type so that we can
* be sure it will serialize/deserialize correctly in state provider.
*/
static fromSendAccessTokenResponse(
sendAccessTokenResponse: SendAccessTokenResponse,
): SendAccessToken {
return new SendAccessToken(sendAccessTokenResponse.token, sendAccessTokenResponse.expiresAt);
}
}

View File

@@ -0,0 +1,678 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import {
SendAccessTokenApiErrorResponse,
SendAccessTokenError,
SendAccessTokenInvalidGrantError,
SendAccessTokenInvalidRequestError,
SendAccessTokenResponse,
UnexpectedIdentityError,
} from "@bitwarden/sdk-internal";
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
import {
SendHashedPassword,
SendPasswordKeyMaterial,
SendPasswordService,
} from "../../../key-management/sends";
import { Utils } from "../../../platform/misc/utils";
import { MockSdkService } from "../../../platform/spec/mock-sdk.service";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { SendOtp } from "../types/send-otp.type";
import { DefaultSendTokenService } from "./default-send-token.service";
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
describe("SendTokenService", () => {
let service: DefaultSendTokenService;
// Deps
let sdkService: MockSdkService;
let globalStateProvider: FakeGlobalStateProvider;
let sendPasswordService: MockProxy<SendPasswordService>;
beforeEach(() => {
globalStateProvider = new FakeGlobalStateProvider();
sdkService = new MockSdkService();
sendPasswordService = mock<SendPasswordService>();
service = new DefaultSendTokenService(globalStateProvider, sdkService, sendPasswordService);
});
it("instantiates", () => {
expect(service).toBeTruthy();
});
describe("Send access token retrieval tests", () => {
let sendAccessTokenDictGlobalState: FakeGlobalState<Record<string, SendAccessToken>>;
let sendAccessTokenResponse: SendAccessTokenResponse;
let sendId: string;
let sendAccessToken: SendAccessToken;
let token: string;
let tokenExpiresAt: number;
const EXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "expected_server";
const UNEXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "unexpected_server";
const INVALID_REQUEST_CODES: SendAccessTokenInvalidRequestError[] = [
"send_id_required",
"password_hash_b64_required",
"email_required",
"email_and_otp_required_otp_sent",
"unknown",
];
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
"send_id_invalid",
"password_hash_b64_invalid",
"email_invalid",
"otp_invalid",
"otp_generation_failed",
"unknown",
];
const CREDS = [
{ kind: "password", passwordHashB64: "h4sh" as SendHashedPasswordB64 },
{ kind: "email", email: "u@example.com" },
{ kind: "email_otp", email: "u@example.com", otp: "123456" as SendOtp },
] as const satisfies readonly SendAccessDomainCredentials[];
type SendAccessTokenApiErrorResponseErrorCode = SendAccessTokenApiErrorResponse["error"];
type SimpleErrorType = Exclude<
SendAccessTokenApiErrorResponseErrorCode,
"invalid_request" | "invalid_grant"
>;
// Extract out simple error types which don't have complex send_access_error_types to handle.
const SIMPLE_ERROR_TYPES = [
"invalid_client",
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
"invalid_target",
] as const satisfies readonly SimpleErrorType[];
beforeEach(() => {
sendId = "sendId";
token = "sendAccessToken";
tokenExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes from now
sendAccessTokenResponse = {
token: token,
expiresAt: tokenExpiresAt,
};
sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(sendAccessTokenResponse);
sendAccessTokenDictGlobalState = globalStateProvider.getFake(SEND_ACCESS_TOKEN_DICT);
// Ensure the state is empty before each test
sendAccessTokenDictGlobalState.stateSubject.next({});
});
describe("tryGetSendAccessToken$", () => {
it("returns the send access token from session storage when token exists and isn't expired", async () => {
// Arrange
// Store the send access token in the global state
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual(sendAccessToken);
});
it("returns expired error and clears token from storage when token is expired", async () => {
// Arrange
const oldDate = new Date("2025-01-01");
const expiredSendAccessToken = new SendAccessToken(token, oldDate.getTime());
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: expiredSendAccessToken });
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).not.toBeInstanceOf(SendAccessToken);
expect(result).toStrictEqual({ kind: "expired" });
// assert that we removed the expired token from storage.
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
});
it("calls to get a new token if none is found in storage and stores the retrieved token in session storage", async () => {
// Arrange
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toBeInstanceOf(SendAccessToken);
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
describe("handles expected invalid_request scenarios appropriately", () => {
it.each(INVALID_REQUEST_CODES)(
"surfaces %s as an expected invalid_request error",
async (code) => {
// Arrange
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles bare expected invalid_request scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
});
});
it.each(SIMPLE_ERROR_TYPES)("handles expected %s error appropriately", async (errorType) => {
const api: SendAccessTokenApiErrorResponse = {
error: errorType,
error_description: `The ${errorType} error occurred`,
};
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
});
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s bare error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = { error: errorType };
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
describe("handles expected invalid_grant scenarios appropriately", () => {
it.each(INVALID_GRANT_CODES)(
"surfaces %s as an expected invalid_grant error",
async (code) => {
// Arrange
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles bare expected invalid_grant scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
});
});
it("surfaces unexpected errors as unexpected_server error", async () => {
// Arrange
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
mockSdkRejectWith({
kind: "unexpected",
data: unexpectedIdentityError,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: UNEXPECTED_SERVER_KIND,
error: unexpectedIdentityError,
});
});
it("surfaces an unknown error as an unknown error", async () => {
// Arrange
const unknownError = "unknown error occurred";
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(new Error(unknownError));
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: "unknown",
error: unknownError,
});
});
describe("getSendAccessTokenFromStorage", () => {
it("returns undefined if no token is found in storage", async () => {
const result = await (service as any).getSendAccessTokenFromStorage("nonexistentSendId");
expect(result).toBeUndefined();
});
it("returns the token if found in storage", async () => {
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
expect(result).toEqual(sendAccessToken);
});
it("returns undefined if the global state isn't initialized yet", async () => {
sendAccessTokenDictGlobalState.stateSubject.next(null);
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
expect(result).toBeUndefined();
});
});
describe("setSendAccessTokenInStorage", () => {
it("stores the token in storage", async () => {
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
it("initializes the dictionary if it isn't already", async () => {
sendAccessTokenDictGlobalState.stateSubject.next(null);
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
it("merges with existing tokens in storage", async () => {
const anotherSendId = "anotherSendId";
const anotherSendAccessToken = new SendAccessToken(
"anotherToken",
Date.now() + 1000 * 60,
);
sendAccessTokenDictGlobalState.stateSubject.next({
[anotherSendId]: anotherSendAccessToken,
});
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
expect(sendAccessTokenDict).toHaveProperty(anotherSendId, anotherSendAccessToken);
});
});
});
describe("getSendAccessToken$", () => {
it("returns a send access token for a password protected send when given valid password credentials", async () => {
// Arrange
const sendPasswordCredentials: SendAccessDomainCredentials = {
kind: "password",
passwordHashB64: "testPassword" as SendHashedPasswordB64,
};
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(
service.getSendAccessToken$(sendId, sendPasswordCredentials),
);
// Assert
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
// Note: we deliberately aren't testing the "success" scenario of passing
// just SendEmailCredentials as that will never return a send access token on it's own.
it("returns a send access token for a email + otp protected send when given valid email and otp", async () => {
// Arrange
const sendEmailOtpCredentials: SendAccessDomainCredentials = {
kind: "email_otp",
email: "test@example.com",
otp: "123456" as SendOtp,
};
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(
service.getSendAccessToken$(sendId, sendEmailOtpCredentials),
);
// Assert
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
describe.each(CREDS.map((c) => [c.kind, c] as const))(
"scenarios with %s credentials",
(_label, creds) => {
it.each(INVALID_REQUEST_CODES)(
"handles expected invalid_request.%s scenario appropriately",
async (code) => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles expected invalid_request scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
});
it.each(INVALID_GRANT_CODES)(
"handles expected invalid_grant.%s scenario appropriately",
async (code) => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles expected invalid_grant scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
});
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = {
error: errorType,
error_description: `The ${errorType} error occurred`,
};
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s bare error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = { error: errorType };
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
it("surfaces unexpected errors as unexpected_server error", async () => {
// Arrange
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
mockSdkRejectWith({
kind: "unexpected",
data: unexpectedIdentityError,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: UNEXPECTED_SERVER_KIND,
error: unexpectedIdentityError,
});
});
it("surfaces an unknown error as an unknown error", async () => {
// Arrange
const unknownError = "unknown error occurred";
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(new Error(unknownError));
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "unknown",
error: unknownError,
});
});
},
);
it("errors if passwordHashB64 is missing for password credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "password",
passwordHashB64: "" as SendHashedPasswordB64,
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"passwordHashB64 must be provided for password credentials.",
);
});
it("errors if email is missing for email credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "email",
email: "",
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"email must be provided for email credentials.",
);
});
it("errors if email or otp is missing for email_otp credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "email_otp",
email: "",
otp: "" as SendOtp,
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"email and otp must be provided for email_otp credentials.",
);
});
});
describe("invalidateSendAccessToken", () => {
it("removes a send access token from storage", async () => {
// Arrange
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
// Act
await service.invalidateSendAccessToken(sendId);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
// Assert
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
});
});
});
describe("hashSendPassword", () => {
test.each(["", null, undefined])("rejects if password is %p", async (pwd) => {
await expect(service.hashSendPassword(pwd as any, "keyMaterialUrlB64")).rejects.toThrow(
"Password must be provided.",
);
});
test.each(["", null, undefined])(
"rejects if keyMaterialUrlB64 is %p",
async (keyMaterialUrlB64) => {
await expect(
service.hashSendPassword("password", keyMaterialUrlB64 as any),
).rejects.toThrow("KeyMaterialUrlB64 must be provided.");
},
);
it("correctly hashes the password", async () => {
// Arrange
const password = "testPassword";
const keyMaterialUrlB64 = "testKeyMaterialUrlB64";
const keyMaterialArray = new Uint8Array([1, 2, 3]) as SendPasswordKeyMaterial;
const hashedPasswordArray = new Uint8Array([4, 5, 6]) as SendHashedPassword;
const sendHashedPasswordB64 = "hashedPasswordB64" as SendHashedPasswordB64;
const utilsFromUrlB64ToArraySpy = jest
.spyOn(Utils, "fromUrlB64ToArray")
.mockReturnValue(keyMaterialArray);
sendPasswordService.hashPassword.mockResolvedValue(hashedPasswordArray);
const utilsFromBufferToB64Spy = jest
.spyOn(Utils, "fromBufferToB64")
.mockReturnValue(sendHashedPasswordB64);
// Act
const result = await service.hashSendPassword(password, keyMaterialUrlB64);
// Assert
expect(sendPasswordService.hashPassword).toHaveBeenCalledWith(password, keyMaterialArray);
expect(utilsFromUrlB64ToArraySpy).toHaveBeenCalledWith(keyMaterialUrlB64);
expect(utilsFromBufferToB64Spy).toHaveBeenCalledWith(hashedPasswordArray);
expect(result).toBe(sendHashedPasswordB64);
});
});
function mockSdkRejectWith(sendAccessTokenError: SendAccessTokenError) {
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(sendAccessTokenError);
}
});

View File

@@ -0,0 +1,316 @@
import { Observable, defer, firstValueFrom, from } from "rxjs";
import {
BitwardenClient,
SendAccessCredentials,
SendAccessTokenError,
SendAccessTokenRequest,
SendAccessTokenResponse,
} from "@bitwarden/sdk-internal";
import { GlobalState, GlobalStateProvider } from "@bitwarden/state";
import { SendPasswordService } from "../../../key-management/sends/abstractions/send-password.service";
import {
SendHashedPassword,
SendPasswordKeyMaterial,
} from "../../../key-management/sends/types/send-hashed-password.type";
import { SdkService } from "../../../platform/abstractions/sdk/sdk.service";
import { Utils } from "../../../platform/misc/utils";
import { SendTokenService as SendTokenServiceAbstraction } from "../abstractions/send-token.service";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
export class DefaultSendTokenService implements SendTokenServiceAbstraction {
private sendAccessTokenDictGlobalState: GlobalState<Record<string, SendAccessToken>> | undefined;
constructor(
private globalStateProvider: GlobalStateProvider,
private sdkService: SdkService,
private sendPasswordService: SendPasswordService,
) {
this.initializeState();
}
private initializeState(): void {
this.sendAccessTokenDictGlobalState = this.globalStateProvider.get(SEND_ACCESS_TOKEN_DICT);
}
tryGetSendAccessToken$(sendId: string): Observable<SendAccessToken | TryGetSendAccessTokenError> {
// Defer the execution to ensure that a cold observable is returned.
return defer(() => from(this._tryGetSendAccessToken(sendId)));
}
private async _tryGetSendAccessToken(
sendId: string,
): Promise<SendAccessToken | TryGetSendAccessTokenError> {
// Validate the sendId is a non-empty string.
this.validateSendId(sendId);
// Check in storage for the access token for the given sendId.
const sendAccessTokenFromStorage = await this.getSendAccessTokenFromStorage(sendId);
if (sendAccessTokenFromStorage != null) {
// If it is expired, we clear the token from storage and return the expired error
if (sendAccessTokenFromStorage.isExpired()) {
await this.clearSendAccessTokenFromStorage(sendId);
return { kind: "expired" };
} else {
// If it is not expired, we return it
return sendAccessTokenFromStorage;
}
}
// If we don't have a token in storage, we can try to request a new token from the server.
const request: SendAccessTokenRequest = {
sendId: sendId,
};
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
try {
const result: SendAccessTokenResponse = await anonSdkClient
.auth()
.send_access()
.request_send_access_token(request);
// Convert from SDK shape to SendAccessToken so it can be serialized into & out of state provider
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
// If we get a token back, we need to store it in the global state.
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
return sendAccessToken;
} catch (error: unknown) {
return this.normalizeSendAccessTokenError(error);
}
}
getSendAccessToken$(
sendId: string,
sendCredentials: SendAccessDomainCredentials,
): Observable<SendAccessToken | GetSendAccessTokenError> {
// Defer the execution to ensure that a cold observable is returned.
return defer(() => from(this._getSendAccessToken(sendId, sendCredentials)));
}
private async _getSendAccessToken(
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
): Promise<SendAccessToken | GetSendAccessTokenError> {
// Validate inputs to account for non-strict TS call sites.
this.validateCredentialsRequest(sendId, sendAccessCredentials);
// Convert inputs to SDK request shape
const request: SendAccessTokenRequest = {
sendId: sendId,
sendAccessCredentials: this.convertDomainCredentialsToSdkCredentials(sendAccessCredentials),
};
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
try {
const result: SendAccessTokenResponse = await anonSdkClient
.auth()
.send_access()
.request_send_access_token(request);
// Convert from SDK interface to SendAccessToken class so it can be serialized into & out of state provider
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
// Any time we get a token from the server, we need to store it in the global state.
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
return sendAccessToken;
} catch (error: unknown) {
return this.normalizeSendAccessTokenError(error);
}
}
async invalidateSendAccessToken(sendId: string): Promise<void> {
await this.clearSendAccessTokenFromStorage(sendId);
}
async hashSendPassword(
password: string,
keyMaterialUrlB64: string,
): Promise<SendHashedPasswordB64> {
// Validate the password and key material
if (password == null || password.trim() === "") {
throw new Error("Password must be provided.");
}
if (keyMaterialUrlB64 == null || keyMaterialUrlB64.trim() === "") {
throw new Error("KeyMaterialUrlB64 must be provided.");
}
// Convert the base64 URL encoded key material to a Uint8Array
const keyMaterialUrlB64Array = Utils.fromUrlB64ToArray(
keyMaterialUrlB64,
) as SendPasswordKeyMaterial;
const sendHashedPasswordArray: SendHashedPassword = await this.sendPasswordService.hashPassword(
password,
keyMaterialUrlB64Array,
);
// Convert the Uint8Array to a base64 encoded string which is required
// for the server to be able to compare the password hash.
const sendHashedPasswordB64 = Utils.fromBufferToB64(
sendHashedPasswordArray,
) as SendHashedPasswordB64;
return sendHashedPasswordB64;
}
private async getSendAccessTokenFromStorage(
sendId: string,
): Promise<SendAccessToken | undefined> {
if (this.sendAccessTokenDictGlobalState != null) {
const sendAccessTokenDict = await firstValueFrom(this.sendAccessTokenDictGlobalState.state$);
return sendAccessTokenDict?.[sendId];
}
return undefined;
}
private async setSendAccessTokenInStorage(
sendId: string,
sendAccessToken: SendAccessToken,
): Promise<void> {
if (this.sendAccessTokenDictGlobalState != null) {
await this.sendAccessTokenDictGlobalState.update(
(sendAccessTokenDict) => {
sendAccessTokenDict ??= {}; // Initialize if undefined
sendAccessTokenDict[sendId] = sendAccessToken;
return sendAccessTokenDict;
},
{
// only update if the value is different (to avoid unnecessary writes)
shouldUpdate: (prevDict) => {
const prevSendAccessToken = prevDict?.[sendId];
return (
prevSendAccessToken?.token !== sendAccessToken.token ||
prevSendAccessToken?.expiresAt !== sendAccessToken.expiresAt
);
},
},
);
}
}
private async clearSendAccessTokenFromStorage(sendId: string): Promise<void> {
if (this.sendAccessTokenDictGlobalState != null) {
await this.sendAccessTokenDictGlobalState.update(
(sendAccessTokenDict) => {
if (!sendAccessTokenDict) {
// If the dict is empty or undefined, there's nothing to clear
return sendAccessTokenDict;
}
if (sendAccessTokenDict[sendId] == null) {
// If the specific sendId does not exist, nothing to clear
return sendAccessTokenDict;
}
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [sendId]: _, ...rest } = sendAccessTokenDict;
return rest;
},
{
// only update if the value is defined (to avoid unnecessary writes)
shouldUpdate: (prevDict) => prevDict?.[sendId] != null,
},
);
}
}
/**
* Normalizes an error from the SDK send access token request process.
* @param e The error to normalize.
* @returns A normalized GetSendAccessTokenError.
*/
private normalizeSendAccessTokenError(e: unknown): GetSendAccessTokenError {
if (this.isSendAccessTokenError(e)) {
if (e.kind === "unexpected") {
return { kind: "unexpected_server", error: e.data };
}
return { kind: "expected_server", error: e.data };
}
if (e instanceof Error) {
return { kind: "unknown", error: e.message };
}
try {
return { kind: "unknown", error: JSON.stringify(e) };
} catch {
return { kind: "unknown", error: "error cannot be stringified" };
}
}
private isSendAccessTokenError(e: unknown): e is SendAccessTokenError {
return (
typeof e === "object" &&
e !== null &&
"kind" in e &&
(e.kind === "expected" || e.kind === "unexpected")
);
}
private validateSendId(sendId: string): void {
if (sendId == null || sendId.trim() === "") {
throw new Error("sendId must be provided.");
}
}
private validateCredentialsRequest(
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
): void {
this.validateSendId(sendId);
if (sendAccessCredentials == null) {
throw new Error("sendAccessCredentials must be provided.");
}
if (sendAccessCredentials.kind === "password" && !sendAccessCredentials.passwordHashB64) {
throw new Error("passwordHashB64 must be provided for password credentials.");
}
if (sendAccessCredentials.kind === "email" && !sendAccessCredentials.email) {
throw new Error("email must be provided for email credentials.");
}
if (
sendAccessCredentials.kind === "email_otp" &&
(!sendAccessCredentials.email || !sendAccessCredentials.otp)
) {
throw new Error("email and otp must be provided for email_otp credentials.");
}
}
private convertDomainCredentialsToSdkCredentials(
sendAccessCredentials: SendAccessDomainCredentials,
): SendAccessCredentials {
switch (sendAccessCredentials.kind) {
case "password":
return {
passwordHashB64: sendAccessCredentials.passwordHashB64,
};
case "email":
return {
email: sendAccessCredentials.email,
};
case "email_otp":
return {
email: sendAccessCredentials.email,
otp: sendAccessCredentials.otp,
};
}
}
}

View File

@@ -0,0 +1 @@
export * from "./default-send-token.service";

View File

@@ -0,0 +1,15 @@
import { Jsonify } from "type-fest";
import { KeyDefinition, SEND_ACCESS_DISK } from "@bitwarden/state";
import { SendAccessToken } from "../models/send-access-token";
export const SEND_ACCESS_TOKEN_DICT = KeyDefinition.record<SendAccessToken, string>(
SEND_ACCESS_DISK,
"accessTokenDict",
{
deserializer: (sendAccessTokenJson: Jsonify<SendAccessToken>) => {
return SendAccessToken.fromJson(sendAccessTokenJson);
},
},
);

View File

@@ -0,0 +1,12 @@
import { UnexpectedIdentityError, SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
/**
* Represents the possible errors that can occur when retrieving a SendAccessToken.
* Note: for expected_server errors, see invalid-request-errors.type.ts and
* invalid-grant-errors.type.ts for type guards that identify specific
* SendAccessTokenApiErrorResponse errors
*/
export type GetSendAccessTokenError =
| { kind: "unexpected_server"; error: UnexpectedIdentityError }
| { kind: "expected_server"; error: SendAccessTokenApiErrorResponse }
| { kind: "unknown"; error: string };

View File

@@ -0,0 +1,7 @@
export * from "./try-get-send-access-token-error.type";
export * from "./send-otp.type";
export * from "./send-hashed-password-b64.type";
export * from "./send-access-domain-credentials.type";
export * from "./invalid-request-errors.type";
export * from "./invalid-grant-errors.type";
export * from "./get-send-access-token-error.type";

View File

@@ -0,0 +1,62 @@
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
export type InvalidGrant = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_grant" }>;
export function isInvalidGrant(e: SendAccessTokenApiErrorResponse): e is InvalidGrant {
return e.error === "invalid_grant";
}
export type BareInvalidGrant = Extract<
SendAccessTokenApiErrorResponse,
{ error: "invalid_grant" }
> & { send_access_error_type?: undefined };
export function isBareInvalidGrant(e: SendAccessTokenApiErrorResponse): e is BareInvalidGrant {
return e.error === "invalid_grant" && e.send_access_error_type === undefined;
}
export type SendIdInvalid = InvalidGrant & {
send_access_error_type: "send_id_invalid";
};
export function sendIdInvalid(e: SendAccessTokenApiErrorResponse): e is SendIdInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "send_id_invalid";
}
export type PasswordHashB64Invalid = InvalidGrant & {
send_access_error_type: "password_hash_b64_invalid";
};
export function passwordHashB64Invalid(
e: SendAccessTokenApiErrorResponse,
): e is PasswordHashB64Invalid {
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
}
export type EmailInvalid = InvalidGrant & {
send_access_error_type: "email_invalid";
};
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
}
export type OtpInvalid = InvalidGrant & {
send_access_error_type: "otp_invalid";
};
export function otpInvalid(e: SendAccessTokenApiErrorResponse): e is OtpInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "otp_invalid";
}
export type OtpGenerationFailed = InvalidGrant & {
send_access_error_type: "otp_generation_failed";
};
export function otpGenerationFailed(e: SendAccessTokenApiErrorResponse): e is OtpGenerationFailed {
return e.error === "invalid_grant" && e.send_access_error_type === "otp_generation_failed";
}
export type UnknownInvalidGrant = InvalidGrant & {
send_access_error_type: "unknown";
};
export function isUnknownInvalidGrant(
e: SendAccessTokenApiErrorResponse,
): e is UnknownInvalidGrant {
return e.error === "invalid_grant" && e.send_access_error_type === "unknown";
}

View File

@@ -0,0 +1,62 @@
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
export type InvalidRequest = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_request" }>;
export function isInvalidRequest(e: SendAccessTokenApiErrorResponse): e is InvalidRequest {
return e.error === "invalid_request";
}
export type BareInvalidRequest = Extract<
SendAccessTokenApiErrorResponse,
{ error: "invalid_request" }
> & { send_access_error_type?: undefined };
export function isBareInvalidRequest(e: SendAccessTokenApiErrorResponse): e is BareInvalidRequest {
return e.error === "invalid_request" && e.send_access_error_type === undefined;
}
export type SendIdRequired = InvalidRequest & {
send_access_error_type: "send_id_required";
};
export function sendIdRequired(e: SendAccessTokenApiErrorResponse): e is SendIdRequired {
return e.error === "invalid_request" && e.send_access_error_type === "send_id_required";
}
export type PasswordHashB64Required = InvalidRequest & {
send_access_error_type: "password_hash_b64_required";
};
export function passwordHashB64Required(
e: SendAccessTokenApiErrorResponse,
): e is PasswordHashB64Required {
return e.error === "invalid_request" && e.send_access_error_type === "password_hash_b64_required";
}
export type EmailRequired = InvalidRequest & { send_access_error_type: "email_required" };
export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailRequired {
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
}
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
send_access_error_type: "email_and_otp_required_otp_sent";
};
export function emailAndOtpRequiredEmailSent(
e: SendAccessTokenApiErrorResponse,
): e is EmailAndOtpRequiredEmailSent {
return (
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
);
}
export type UnknownInvalidRequest = InvalidRequest & {
send_access_error_type: "unknown";
};
export function isUnknownInvalidRequest(
e: SendAccessTokenApiErrorResponse,
): e is UnknownInvalidRequest {
return e.error === "invalid_request" && e.send_access_error_type === "unknown";
}

View File

@@ -0,0 +1,11 @@
import { SendHashedPasswordB64 } from "./send-hashed-password-b64.type";
import { SendOtp } from "./send-otp.type";
/**
* The domain facing send access credentials
* Will be internally mapped to the SDK types
*/
export type SendAccessDomainCredentials =
| { kind: "password"; passwordHashB64: SendHashedPasswordB64 }
| { kind: "email"; email: string }
| { kind: "email_otp"; email: string; otp: SendOtp };

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendOtp = Opaque<string, "SendOtp">;

View File

@@ -0,0 +1,7 @@
import { GetSendAccessTokenError } from "./get-send-access-token-error.type";
/**
* Represents the possible errors that can occur when trying to retrieve a SendAccessToken by
* just a sendId. Extends {@link GetSendAccessTokenError}.
*/
export type TryGetSendAccessTokenError = { kind: "expired" } | GetSendAccessTokenError;

View File

@@ -38,7 +38,7 @@ describe("WebAuthnLoginService", () => {
// We must do this to make the mocked classes available for all the
// assertCredential(...) tests.
global.PublicKeyCredential = MockPublicKeyCredential;
global.PublicKeyCredential = MockPublicKeyCredential as any;
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
// Save the original navigator
@@ -316,6 +316,10 @@ class MockPublicKeyCredential implements PublicKeyCredential {
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
toJSON() {
throw new Error("Method not implemented.");
}
}
function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView {

View File

@@ -1,19 +1,12 @@
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
import { PlanResponse } from "../../billing/models/response/plan.response";
import { ListResponse } from "../../models/response/list.response";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request";
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request";
import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request";
import { InvoicesResponse } from "../models/response/invoices.response";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
export abstract class BillingApiServiceAbstraction {
@@ -29,14 +22,10 @@ export abstract class BillingApiServiceAbstraction {
request: CreateClientOrganizationRequest,
): Promise<void>;
abstract createSetupIntent(paymentMethodType: PaymentMethodType): Promise<string>;
abstract getOrganizationBillingMetadata(
organizationId: string,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
@@ -49,44 +38,12 @@ export abstract class BillingApiServiceAbstraction {
abstract getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse>;
abstract getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse>;
abstract updateOrganizationPaymentMethod(
organizationId: string,
request: UpdatePaymentMethodRequest,
): Promise<void>;
abstract updateOrganizationTaxInformation(
organizationId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void>;
abstract updateProviderClientOrganization(
providerId: string,
organizationId: string,
request: UpdateClientOrganizationRequest,
): Promise<any>;
abstract updateProviderPaymentMethod(
providerId: string,
request: UpdatePaymentMethodRequest,
): Promise<void>;
abstract updateProviderTaxInformation(
providerId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void>;
abstract verifyOrganizationBankAccount(
organizationId: string,
request: VerifyBankAccountRequest,
): Promise<void>;
abstract verifyProviderBankAccount(
providerId: string,
request: VerifyBankAccountRequest,
): Promise<void>;
abstract restartSubscription(
organizationId: string,
request: OrganizationCreateRequest,

View File

@@ -3,7 +3,6 @@ import { UserId } from "@bitwarden/user-core";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
export type OrganizationInformation = {
name: string;
@@ -45,8 +44,6 @@ export type SubscriptionInformation = {
};
export abstract class OrganizationBillingServiceAbstraction {
abstract getPaymentSource(organizationId: string): Promise<PaymentSourceResponse>;
abstract purchaseSubscription(
subscription: SubscriptionInformation,
activeUserId: UserId,

View File

@@ -1,23 +0,0 @@
import { CountryListItem } from "../models/domain";
import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request";
import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax";
import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response";
export abstract class TaxServiceAbstraction {
abstract getCountries(): CountryListItem[];
abstract isCountrySupported(country: string): Promise<boolean>;
abstract previewIndividualInvoice(
request: PreviewIndividualInvoiceRequest,
): Promise<PreviewInvoiceResponse>;
abstract previewOrganizationInvoice(
request: PreviewOrganizationInvoiceRequest,
): Promise<PreviewInvoiceResponse>;
abstract previewTaxAmountForOrganizationTrial: (
request: PreviewTaxAmountForOrganizationTrialRequest,
) => Promise<number>;
}

View File

@@ -1,6 +0,0 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum BitwardenProductType {
PasswordManager = 0,
SecretsManager = 1,
}

View File

@@ -2,7 +2,6 @@ export * from "./payment-method-type.enum";
export * from "./plan-sponsorship-type.enum";
export * from "./plan-type.enum";
export * from "./transaction-type.enum";
export * from "./bitwarden-product-type.enum";
export * from "./product-tier-type.enum";
export * from "./product-type.enum";
export * from "./plan-interval.enum";

View File

@@ -1,29 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TaxInformation } from "../domain/tax-information";
import { TaxInfoUpdateRequest } from "./tax-info-update.request";
export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest {
taxId: string;
line1: string;
line2: string;
city: string;
state: string;
static From(taxInformation: TaxInformation): ExpandedTaxInfoUpdateRequest {
if (!taxInformation) {
return null;
}
const request = new ExpandedTaxInfoUpdateRequest();
request.country = taxInformation.country;
request.postalCode = taxInformation.postalCode;
request.taxId = taxInformation.taxId;
request.line1 = taxInformation.line1;
request.line2 = taxInformation.line2;
request.city = taxInformation.city;
request.state = taxInformation.state;
return request;
}
}

View File

@@ -1,10 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { PaymentMethodType } from "../../enums";
import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request";
export class PaymentRequest extends ExpandedTaxInfoUpdateRequest {
paymentMethodType: PaymentMethodType;
paymentToken: string;
}

View File

@@ -1,28 +0,0 @@
// @ts-strict-ignore
export class PreviewIndividualInvoiceRequest {
passwordManager: PasswordManager;
taxInformation: TaxInformation;
constructor(passwordManager: PasswordManager, taxInformation: TaxInformation) {
this.passwordManager = passwordManager;
this.taxInformation = taxInformation;
}
}
class PasswordManager {
additionalStorage: number;
constructor(additionalStorage: number) {
this.additionalStorage = additionalStorage;
}
}
class TaxInformation {
postalCode: string;
country: string;
constructor(postalCode: string, country: string) {
this.postalCode = postalCode;
this.country = country;
}
}

View File

@@ -1,55 +0,0 @@
import { PlanSponsorshipType, PlanType } from "../../enums";
export class PreviewOrganizationInvoiceRequest {
organizationId?: string;
passwordManager: PasswordManager;
secretsManager?: SecretsManager;
taxInformation: TaxInformation;
constructor(
passwordManager: PasswordManager,
taxInformation: TaxInformation,
organizationId?: string,
secretsManager?: SecretsManager,
) {
this.organizationId = organizationId;
this.passwordManager = passwordManager;
this.secretsManager = secretsManager;
this.taxInformation = taxInformation;
}
}
class PasswordManager {
plan: PlanType;
sponsoredPlan?: PlanSponsorshipType;
seats: number;
additionalStorage: number;
constructor(plan: PlanType, seats: number, additionalStorage: number) {
this.plan = plan;
this.seats = seats;
this.additionalStorage = additionalStorage;
}
}
class SecretsManager {
seats: number;
additionalMachineAccounts: number;
constructor(seats: number, additionalMachineAccounts: number) {
this.seats = seats;
this.additionalMachineAccounts = additionalMachineAccounts;
}
}
class TaxInformation {
postalCode: string;
country: string;
taxId: string;
constructor(postalCode: string, country: string, taxId: string) {
this.postalCode = postalCode;
this.country = country;
this.taxId = taxId;
}
}

View File

@@ -1,6 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class TaxInfoUpdateRequest {
country: string;
postalCode: string;
}

View File

@@ -1 +0,0 @@
export * from "./preview-tax-amount-for-organization-trial.request";

View File

@@ -1,11 +0,0 @@
import { PlanType, ProductType } from "../../../enums";
export type PreviewTaxAmountForOrganizationTrialRequest = {
planType: PlanType;
productType: ProductType;
taxInformation: {
country: string;
postalCode: string;
taxId?: string;
};
};

View File

@@ -1,8 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { PaymentMethodType } from "../../enums";
export class TokenizedPaymentSourceRequest {
type: PaymentMethodType;
token: string;
}

View File

@@ -1,9 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "./tokenized-payment-source.request";
export class UpdatePaymentMethodRequest {
paymentSource: TokenizedPaymentSourceRequest;
taxInformation: ExpandedTaxInfoUpdateRequest;
}

View File

@@ -1,7 +0,0 @@
export class VerifyBankAccountRequest {
descriptorCode: string;
constructor(descriptorCode: string) {
this.descriptorCode = descriptorCode;
}
}

View File

@@ -1,17 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../models/response/base.response";
import { BillingSourceResponse } from "./billing.response";
export class BillingPaymentResponse extends BaseResponse {
balance: number;
paymentSource: BillingSourceResponse;
constructor(response: any) {
super(response);
this.balance = this.getResponseProperty("Balance");
const paymentSource = this.getResponseProperty("PaymentSource");
this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource);
}
}

View File

@@ -1,28 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PaymentSourceResponse } from "./payment-source.response";
import { TaxInfoResponse } from "./tax-info.response";
export class PaymentMethodResponse extends BaseResponse {
accountCredit: number;
paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string;
taxInformation?: TaxInfoResponse;
constructor(response: any) {
super(response);
this.accountCredit = this.getResponseProperty("AccountCredit");
const paymentSource = this.getResponseProperty("PaymentSource");
if (paymentSource) {
this.paymentSource = new PaymentSourceResponse(paymentSource);
}
this.subscriptionStatus = this.getResponseProperty("SubscriptionStatus");
const taxInformation = this.getResponseProperty("TaxInformation");
if (taxInformation) {
this.taxInformation = new TaxInfoResponse(taxInformation);
}
}
}

View File

@@ -1,28 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
export class TaxIdTypesResponse extends BaseResponse {
taxIdTypes: TaxIdTypeResponse[] = [];
constructor(response: any) {
super(response);
const taxIdTypes = this.getResponseProperty("TaxIdTypes");
if (taxIdTypes && taxIdTypes.length) {
this.taxIdTypes = taxIdTypes.map((t: any) => new TaxIdTypeResponse(t));
}
}
}
export class TaxIdTypeResponse extends BaseResponse {
code: string;
country: string;
description: string;
example: string;
constructor(response: any) {
super(response);
this.code = this.getResponseProperty("Code");
this.country = this.getResponseProperty("Country");
this.description = this.getResponseProperty("Description");
this.example = this.getResponseProperty("Example");
}
}

View File

@@ -1 +0,0 @@
export * from "./preview-tax-amount.response";

View File

@@ -1,11 +0,0 @@
import { BaseResponse } from "../../../../models/response/base.response";
export class PreviewTaxAmountResponse extends BaseResponse {
taxAmount: number;
constructor(response: any) {
super(response);
this.taxAmount = this.getResponseProperty("TaxAmount");
}
}

View File

@@ -1,23 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ListResponse } from "../../models/response/list.response";
import { BillingApiServiceAbstraction } from "../abstractions";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request";
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request";
import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request";
import { InvoicesResponse } from "../models/response/invoices.response";
import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
import { PlanResponse } from "../models/response/plan.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
@@ -54,21 +47,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async createSetupIntent(type: PaymentMethodType) {
const getPath = () => {
switch (type) {
case PaymentMethodType.BankAccount: {
return "/setup-intent/bank-account";
}
case PaymentMethodType.Card: {
return "/setup-intent/card";
}
}
};
const response = await this.apiService.send("POST", getPath(), null, true, true);
return response as string;
}
async getOrganizationBillingMetadata(
organizationId: string,
): Promise<OrganizationBillingMetadataResponse> {
@@ -83,17 +61,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse> {
const response = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
);
return new PaymentMethodResponse(response);
}
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, false, true);
return new ListResponse(r, PlanResponse);
@@ -145,43 +112,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new ProviderSubscriptionResponse(response);
}
async getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/tax-information",
null,
true,
true,
);
return new TaxInfoResponse(response);
}
async updateOrganizationPaymentMethod(
organizationId: string,
request: UpdatePaymentMethodRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/billing/payment-method",
request,
true,
false,
);
}
async updateOrganizationTaxInformation(
organizationId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/billing/tax-information",
request,
true,
false,
);
}
async updateProviderClientOrganization(
providerId: string,
organizationId: string,
@@ -196,55 +126,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async updateProviderPaymentMethod(
providerId: string,
request: UpdatePaymentMethodRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/providers/" + providerId + "/billing/payment-method",
request,
true,
false,
);
}
async updateProviderTaxInformation(providerId: string, request: ExpandedTaxInfoUpdateRequest) {
return await this.apiService.send(
"PUT",
"/providers/" + providerId + "/billing/tax-information",
request,
true,
false,
);
}
async verifyOrganizationBankAccount(
organizationId: string,
request: VerifyBankAccountRequest,
): Promise<void> {
return await this.apiService.send(
"POST",
"/organizations/" + organizationId + "/billing/payment-method/verify-bank-account",
request,
true,
false,
);
}
async verifyProviderBankAccount(
providerId: string,
request: VerifyBankAccountRequest,
): Promise<void> {
return await this.apiService.send(
"POST",
"/providers/" + providerId + "/billing/payment-method/verify-bank-account",
request,
true,
false,
);
}
async restartSubscription(
organizationId: string,
request: OrganizationCreateRequest,

View File

@@ -23,7 +23,6 @@ import { OrganizationResponse } from "../../admin-console/models/response/organi
import { EncString } from "../../key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { OrgKey } from "../../types/key";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
describe("OrganizationBillingService", () => {
let apiService: jest.Mocked<ApiService>;
@@ -62,47 +61,6 @@ describe("OrganizationBillingService", () => {
return jest.resetAllMocks();
});
describe("getPaymentSource()", () => {
it("given a valid organization id, then it returns a payment source", async () => {
//Arrange
const orgId = "organization-test";
const paymentMethodResponse = {
paymentSource: { type: PaymentMethodType.Card },
} as PaymentMethodResponse;
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse);
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource);
});
it("given an invalid organizationId, it should return undefined", async () => {
//Arrange
const orgId = "invalid-id";
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null);
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toBeUndefined();
});
it("given an API error occurs, then it throws the error", async () => {
// Arrange
const orgId = "error-org";
billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error"));
// Act & Assert
await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error");
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
});
});
describe("purchaseSubscription()", () => {
it("given valid subscription information, then it returns successful response", async () => {
//Arrange
@@ -118,7 +76,7 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);
@@ -201,8 +159,8 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
plan: { type: subscriptionInformation.plan.type },
planType: subscriptionInformation.plan.type,
plan: { type: subscriptionInformation.plan!.type },
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse);
@@ -262,7 +220,7 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);

View File

@@ -25,7 +25,6 @@ import {
} from "../abstractions";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
interface OrganizationKeys {
encryptedKey: EncString;
@@ -45,11 +44,6 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private syncService: SyncService,
) {}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId);
return paymentMethod?.paymentSource;
}
async purchaseSubscription(
subscription: SubscriptionInformation,
activeUserId: UserId,

View File

@@ -1,318 +0,0 @@
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
import { ApiService } from "../../abstractions/api.service";
import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction";
import { CountryListItem } from "../models/domain";
import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request";
import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response";
export class TaxService implements TaxServiceAbstraction {
constructor(private apiService: ApiService) {}
getCountries(): CountryListItem[] {
return [
{ name: "-- Select --", value: "", disabled: false },
{ name: "United States", value: "US", disabled: false },
{ name: "China", value: "CN", disabled: false },
{ name: "France", value: "FR", disabled: false },
{ name: "Germany", value: "DE", disabled: false },
{ name: "Canada", value: "CA", disabled: false },
{ name: "United Kingdom", value: "GB", disabled: false },
{ name: "Australia", value: "AU", disabled: false },
{ name: "India", value: "IN", disabled: false },
{ name: "", value: "-", disabled: true },
{ name: "Afghanistan", value: "AF", disabled: false },
{ name: "Åland Islands", value: "AX", disabled: false },
{ name: "Albania", value: "AL", disabled: false },
{ name: "Algeria", value: "DZ", disabled: false },
{ name: "American Samoa", value: "AS", disabled: false },
{ name: "Andorra", value: "AD", disabled: false },
{ name: "Angola", value: "AO", disabled: false },
{ name: "Anguilla", value: "AI", disabled: false },
{ name: "Antarctica", value: "AQ", disabled: false },
{ name: "Antigua and Barbuda", value: "AG", disabled: false },
{ name: "Argentina", value: "AR", disabled: false },
{ name: "Armenia", value: "AM", disabled: false },
{ name: "Aruba", value: "AW", disabled: false },
{ name: "Austria", value: "AT", disabled: false },
{ name: "Azerbaijan", value: "AZ", disabled: false },
{ name: "Bahamas", value: "BS", disabled: false },
{ name: "Bahrain", value: "BH", disabled: false },
{ name: "Bangladesh", value: "BD", disabled: false },
{ name: "Barbados", value: "BB", disabled: false },
{ name: "Belarus", value: "BY", disabled: false },
{ name: "Belgium", value: "BE", disabled: false },
{ name: "Belize", value: "BZ", disabled: false },
{ name: "Benin", value: "BJ", disabled: false },
{ name: "Bermuda", value: "BM", disabled: false },
{ name: "Bhutan", value: "BT", disabled: false },
{ name: "Bolivia, Plurinational State of", value: "BO", disabled: false },
{ name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false },
{ name: "Bosnia and Herzegovina", value: "BA", disabled: false },
{ name: "Botswana", value: "BW", disabled: false },
{ name: "Bouvet Island", value: "BV", disabled: false },
{ name: "Brazil", value: "BR", disabled: false },
{ name: "British Indian Ocean Territory", value: "IO", disabled: false },
{ name: "Brunei Darussalam", value: "BN", disabled: false },
{ name: "Bulgaria", value: "BG", disabled: false },
{ name: "Burkina Faso", value: "BF", disabled: false },
{ name: "Burundi", value: "BI", disabled: false },
{ name: "Cambodia", value: "KH", disabled: false },
{ name: "Cameroon", value: "CM", disabled: false },
{ name: "Cape Verde", value: "CV", disabled: false },
{ name: "Cayman Islands", value: "KY", disabled: false },
{ name: "Central African Republic", value: "CF", disabled: false },
{ name: "Chad", value: "TD", disabled: false },
{ name: "Chile", value: "CL", disabled: false },
{ name: "Christmas Island", value: "CX", disabled: false },
{ name: "Cocos (Keeling) Islands", value: "CC", disabled: false },
{ name: "Colombia", value: "CO", disabled: false },
{ name: "Comoros", value: "KM", disabled: false },
{ name: "Congo", value: "CG", disabled: false },
{ name: "Congo, the Democratic Republic of the", value: "CD", disabled: false },
{ name: "Cook Islands", value: "CK", disabled: false },
{ name: "Costa Rica", value: "CR", disabled: false },
{ name: "Côte d'Ivoire", value: "CI", disabled: false },
{ name: "Croatia", value: "HR", disabled: false },
{ name: "Cuba", value: "CU", disabled: false },
{ name: "Curaçao", value: "CW", disabled: false },
{ name: "Cyprus", value: "CY", disabled: false },
{ name: "Czech Republic", value: "CZ", disabled: false },
{ name: "Denmark", value: "DK", disabled: false },
{ name: "Djibouti", value: "DJ", disabled: false },
{ name: "Dominica", value: "DM", disabled: false },
{ name: "Dominican Republic", value: "DO", disabled: false },
{ name: "Ecuador", value: "EC", disabled: false },
{ name: "Egypt", value: "EG", disabled: false },
{ name: "El Salvador", value: "SV", disabled: false },
{ name: "Equatorial Guinea", value: "GQ", disabled: false },
{ name: "Eritrea", value: "ER", disabled: false },
{ name: "Estonia", value: "EE", disabled: false },
{ name: "Ethiopia", value: "ET", disabled: false },
{ name: "Falkland Islands (Malvinas)", value: "FK", disabled: false },
{ name: "Faroe Islands", value: "FO", disabled: false },
{ name: "Fiji", value: "FJ", disabled: false },
{ name: "Finland", value: "FI", disabled: false },
{ name: "French Guiana", value: "GF", disabled: false },
{ name: "French Polynesia", value: "PF", disabled: false },
{ name: "French Southern Territories", value: "TF", disabled: false },
{ name: "Gabon", value: "GA", disabled: false },
{ name: "Gambia", value: "GM", disabled: false },
{ name: "Georgia", value: "GE", disabled: false },
{ name: "Ghana", value: "GH", disabled: false },
{ name: "Gibraltar", value: "GI", disabled: false },
{ name: "Greece", value: "GR", disabled: false },
{ name: "Greenland", value: "GL", disabled: false },
{ name: "Grenada", value: "GD", disabled: false },
{ name: "Guadeloupe", value: "GP", disabled: false },
{ name: "Guam", value: "GU", disabled: false },
{ name: "Guatemala", value: "GT", disabled: false },
{ name: "Guernsey", value: "GG", disabled: false },
{ name: "Guinea", value: "GN", disabled: false },
{ name: "Guinea-Bissau", value: "GW", disabled: false },
{ name: "Guyana", value: "GY", disabled: false },
{ name: "Haiti", value: "HT", disabled: false },
{ name: "Heard Island and McDonald Islands", value: "HM", disabled: false },
{ name: "Holy See (Vatican City State)", value: "VA", disabled: false },
{ name: "Honduras", value: "HN", disabled: false },
{ name: "Hong Kong", value: "HK", disabled: false },
{ name: "Hungary", value: "HU", disabled: false },
{ name: "Iceland", value: "IS", disabled: false },
{ name: "Indonesia", value: "ID", disabled: false },
{ name: "Iran, Islamic Republic of", value: "IR", disabled: false },
{ name: "Iraq", value: "IQ", disabled: false },
{ name: "Ireland", value: "IE", disabled: false },
{ name: "Isle of Man", value: "IM", disabled: false },
{ name: "Israel", value: "IL", disabled: false },
{ name: "Italy", value: "IT", disabled: false },
{ name: "Jamaica", value: "JM", disabled: false },
{ name: "Japan", value: "JP", disabled: false },
{ name: "Jersey", value: "JE", disabled: false },
{ name: "Jordan", value: "JO", disabled: false },
{ name: "Kazakhstan", value: "KZ", disabled: false },
{ name: "Kenya", value: "KE", disabled: false },
{ name: "Kiribati", value: "KI", disabled: false },
{ name: "Korea, Democratic People's Republic of", value: "KP", disabled: false },
{ name: "Korea, Republic of", value: "KR", disabled: false },
{ name: "Kuwait", value: "KW", disabled: false },
{ name: "Kyrgyzstan", value: "KG", disabled: false },
{ name: "Lao People's Democratic Republic", value: "LA", disabled: false },
{ name: "Latvia", value: "LV", disabled: false },
{ name: "Lebanon", value: "LB", disabled: false },
{ name: "Lesotho", value: "LS", disabled: false },
{ name: "Liberia", value: "LR", disabled: false },
{ name: "Libya", value: "LY", disabled: false },
{ name: "Liechtenstein", value: "LI", disabled: false },
{ name: "Lithuania", value: "LT", disabled: false },
{ name: "Luxembourg", value: "LU", disabled: false },
{ name: "Macao", value: "MO", disabled: false },
{ name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false },
{ name: "Madagascar", value: "MG", disabled: false },
{ name: "Malawi", value: "MW", disabled: false },
{ name: "Malaysia", value: "MY", disabled: false },
{ name: "Maldives", value: "MV", disabled: false },
{ name: "Mali", value: "ML", disabled: false },
{ name: "Malta", value: "MT", disabled: false },
{ name: "Marshall Islands", value: "MH", disabled: false },
{ name: "Martinique", value: "MQ", disabled: false },
{ name: "Mauritania", value: "MR", disabled: false },
{ name: "Mauritius", value: "MU", disabled: false },
{ name: "Mayotte", value: "YT", disabled: false },
{ name: "Mexico", value: "MX", disabled: false },
{ name: "Micronesia, Federated States of", value: "FM", disabled: false },
{ name: "Moldova, Republic of", value: "MD", disabled: false },
{ name: "Monaco", value: "MC", disabled: false },
{ name: "Mongolia", value: "MN", disabled: false },
{ name: "Montenegro", value: "ME", disabled: false },
{ name: "Montserrat", value: "MS", disabled: false },
{ name: "Morocco", value: "MA", disabled: false },
{ name: "Mozambique", value: "MZ", disabled: false },
{ name: "Myanmar", value: "MM", disabled: false },
{ name: "Namibia", value: "NA", disabled: false },
{ name: "Nauru", value: "NR", disabled: false },
{ name: "Nepal", value: "NP", disabled: false },
{ name: "Netherlands", value: "NL", disabled: false },
{ name: "New Caledonia", value: "NC", disabled: false },
{ name: "New Zealand", value: "NZ", disabled: false },
{ name: "Nicaragua", value: "NI", disabled: false },
{ name: "Niger", value: "NE", disabled: false },
{ name: "Nigeria", value: "NG", disabled: false },
{ name: "Niue", value: "NU", disabled: false },
{ name: "Norfolk Island", value: "NF", disabled: false },
{ name: "Northern Mariana Islands", value: "MP", disabled: false },
{ name: "Norway", value: "NO", disabled: false },
{ name: "Oman", value: "OM", disabled: false },
{ name: "Pakistan", value: "PK", disabled: false },
{ name: "Palau", value: "PW", disabled: false },
{ name: "Palestinian Territory, Occupied", value: "PS", disabled: false },
{ name: "Panama", value: "PA", disabled: false },
{ name: "Papua New Guinea", value: "PG", disabled: false },
{ name: "Paraguay", value: "PY", disabled: false },
{ name: "Peru", value: "PE", disabled: false },
{ name: "Philippines", value: "PH", disabled: false },
{ name: "Pitcairn", value: "PN", disabled: false },
{ name: "Poland", value: "PL", disabled: false },
{ name: "Portugal", value: "PT", disabled: false },
{ name: "Puerto Rico", value: "PR", disabled: false },
{ name: "Qatar", value: "QA", disabled: false },
{ name: "Réunion", value: "RE", disabled: false },
{ name: "Romania", value: "RO", disabled: false },
{ name: "Russian Federation", value: "RU", disabled: false },
{ name: "Rwanda", value: "RW", disabled: false },
{ name: "Saint Barthélemy", value: "BL", disabled: false },
{ name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false },
{ name: "Saint Kitts and Nevis", value: "KN", disabled: false },
{ name: "Saint Lucia", value: "LC", disabled: false },
{ name: "Saint Martin (French part)", value: "MF", disabled: false },
{ name: "Saint Pierre and Miquelon", value: "PM", disabled: false },
{ name: "Saint Vincent and the Grenadines", value: "VC", disabled: false },
{ name: "Samoa", value: "WS", disabled: false },
{ name: "San Marino", value: "SM", disabled: false },
{ name: "Sao Tome and Principe", value: "ST", disabled: false },
{ name: "Saudi Arabia", value: "SA", disabled: false },
{ name: "Senegal", value: "SN", disabled: false },
{ name: "Serbia", value: "RS", disabled: false },
{ name: "Seychelles", value: "SC", disabled: false },
{ name: "Sierra Leone", value: "SL", disabled: false },
{ name: "Singapore", value: "SG", disabled: false },
{ name: "Sint Maarten (Dutch part)", value: "SX", disabled: false },
{ name: "Slovakia", value: "SK", disabled: false },
{ name: "Slovenia", value: "SI", disabled: false },
{ name: "Solomon Islands", value: "SB", disabled: false },
{ name: "Somalia", value: "SO", disabled: false },
{ name: "South Africa", value: "ZA", disabled: false },
{ name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false },
{ name: "South Sudan", value: "SS", disabled: false },
{ name: "Spain", value: "ES", disabled: false },
{ name: "Sri Lanka", value: "LK", disabled: false },
{ name: "Sudan", value: "SD", disabled: false },
{ name: "Suriname", value: "SR", disabled: false },
{ name: "Svalbard and Jan Mayen", value: "SJ", disabled: false },
{ name: "Swaziland", value: "SZ", disabled: false },
{ name: "Sweden", value: "SE", disabled: false },
{ name: "Switzerland", value: "CH", disabled: false },
{ name: "Syrian Arab Republic", value: "SY", disabled: false },
{ name: "Taiwan", value: "TW", disabled: false },
{ name: "Tajikistan", value: "TJ", disabled: false },
{ name: "Tanzania, United Republic of", value: "TZ", disabled: false },
{ name: "Thailand", value: "TH", disabled: false },
{ name: "Timor-Leste", value: "TL", disabled: false },
{ name: "Togo", value: "TG", disabled: false },
{ name: "Tokelau", value: "TK", disabled: false },
{ name: "Tonga", value: "TO", disabled: false },
{ name: "Trinidad and Tobago", value: "TT", disabled: false },
{ name: "Tunisia", value: "TN", disabled: false },
{ name: "Turkey", value: "TR", disabled: false },
{ name: "Turkmenistan", value: "TM", disabled: false },
{ name: "Turks and Caicos Islands", value: "TC", disabled: false },
{ name: "Tuvalu", value: "TV", disabled: false },
{ name: "Uganda", value: "UG", disabled: false },
{ name: "Ukraine", value: "UA", disabled: false },
{ name: "United Arab Emirates", value: "AE", disabled: false },
{ name: "United States Minor Outlying Islands", value: "UM", disabled: false },
{ name: "Uruguay", value: "UY", disabled: false },
{ name: "Uzbekistan", value: "UZ", disabled: false },
{ name: "Vanuatu", value: "VU", disabled: false },
{ name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false },
{ name: "Viet Nam", value: "VN", disabled: false },
{ name: "Virgin Islands, British", value: "VG", disabled: false },
{ name: "Virgin Islands, U.S.", value: "VI", disabled: false },
{ name: "Wallis and Futuna", value: "WF", disabled: false },
{ name: "Western Sahara", value: "EH", disabled: false },
{ name: "Yemen", value: "YE", disabled: false },
{ name: "Zambia", value: "ZM", disabled: false },
{ name: "Zimbabwe", value: "ZW", disabled: false },
];
}
async isCountrySupported(country: string): Promise<boolean> {
const response = await this.apiService.send(
"GET",
"/tax/is-country-supported?country=" + country,
null,
true,
true,
);
return response;
}
async previewIndividualInvoice(
request: PreviewIndividualInvoiceRequest,
): Promise<PreviewInvoiceResponse> {
const response = await this.apiService.send(
"POST",
"/accounts/billing/preview-invoice",
request,
true,
true,
);
return new PreviewInvoiceResponse(response);
}
async previewOrganizationInvoice(
request: PreviewOrganizationInvoiceRequest,
): Promise<PreviewInvoiceResponse> {
const response = await this.apiService.send(
"POST",
`/invoices/preview-organization`,
request,
true,
true,
);
return new PreviewInvoiceResponse(response);
}
async previewTaxAmountForOrganizationTrial(
request: PreviewTaxAmountForOrganizationTrialRequest,
): Promise<number> {
const response = await this.apiService.send(
"POST",
"/tax/preview-amount/organization-trial",
request,
true,
true,
);
return response as number;
}
}

View File

@@ -108,4 +108,11 @@ export enum EventType {
Project_Created = 2201,
Project_Edited = 2202,
Project_Deleted = 2203,
ServiceAccount_UserAdded = 2300,
ServiceAccount_UserRemoved = 2301,
ServiceAccount_GroupAdded = 2302,
ServiceAccount_GroupRemoved = 2303,
ServiceAccount_Created = 2304,
ServiceAccount_Deleted = 2305,
}

View File

@@ -12,7 +12,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors",
/* Auth */
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
@@ -24,7 +23,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
@@ -74,7 +72,6 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.CollectionVaultRefactor]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
@@ -102,7 +99,6 @@ export const DefaultFeatureFlagValue = {
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,

View File

@@ -24,6 +24,7 @@ export class EventResponse extends BaseResponse {
secretId: string;
projectId: string;
serviceAccountId: string;
grantedServiceAccountId: string;
constructor(response: any) {
super(response);
@@ -48,5 +49,6 @@ export class EventResponse extends BaseResponse {
this.secretId = this.getResponseProperty("SecretId");
this.projectId = this.getResponseProperty("ProjectId");
this.serviceAccountId = this.getResponseProperty("ServiceAccountId");
this.grantedServiceAccountId = this.getResponseProperty("GrantedServiceAccountId");
}
}

View File

@@ -17,13 +17,13 @@ const CanLaunchWhitelist = [
];
export class SafeUrls {
static canLaunch(uri: string): boolean {
static canLaunch(uri: string | null | undefined): boolean {
if (Utils.isNullOrWhitespace(uri)) {
return false;
}
for (let i = 0; i < CanLaunchWhitelist.length; i++) {
if (uri.indexOf(CanLaunchWhitelist[i]) === 0) {
if (uri!.indexOf(CanLaunchWhitelist[i]) === 0) {
return true;
}
}

View File

@@ -302,7 +302,7 @@ describe("Utils Service", () => {
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => {
runInBothEnvironments("should return empty string for an empty ArrayBuffer", () => {
const buffer = new Uint8Array([]).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe("");
@@ -312,6 +312,81 @@ describe("Utils Service", () => {
const b64String = Utils.fromBufferToB64(null);
expect(b64String).toBeNull();
});
runInBothEnvironments("returns null for undefined input", () => {
const b64 = Utils.fromBufferToB64(undefined as unknown as ArrayBuffer);
expect(b64).toBeNull();
});
runInBothEnvironments("returns empty string for empty input", () => {
const b64 = Utils.fromBufferToB64(new ArrayBuffer(0));
expect(b64).toBe("");
});
runInBothEnvironments("accepts Uint8Array directly", () => {
const u8 = new Uint8Array(asciiHelloWorldArray);
const b64 = Utils.fromBufferToB64(u8);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("respects byteOffset/byteLength (view window)", () => {
// [xx, 'hello world', yy] — view should only encode the middle slice
const prefix = [1, 2, 3];
const suffix = [4, 5];
const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]);
const view = new Uint8Array(all.buffer, prefix.length, asciiHelloWorldArray.length);
const b64 = Utils.fromBufferToB64(view);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("handles DataView (ArrayBufferView other than Uint8Array)", () => {
const u8 = new Uint8Array(asciiHelloWorldArray);
const dv = new DataView(u8.buffer, 0, u8.byteLength);
const b64 = Utils.fromBufferToB64(dv);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("handles DataView with offset/length window", () => {
// Buffer: [xx, 'hello world', yy]
const prefix = [9, 9, 9];
const suffix = [8, 8];
const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]);
// DataView over just the "hello world" window
const dv = new DataView(all.buffer, prefix.length, asciiHelloWorldArray.length);
const b64 = Utils.fromBufferToB64(dv);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments(
"encodes empty view (offset-length window of zero) as empty string",
() => {
const backing = new Uint8Array([1, 2, 3, 4]);
const emptyView = new Uint8Array(backing.buffer, 2, 0);
const b64 = Utils.fromBufferToB64(emptyView);
expect(b64).toBe("");
},
);
runInBothEnvironments("does not mutate the input", () => {
const original = new Uint8Array(asciiHelloWorldArray);
const copyBefore = new Uint8Array(original); // snapshot
Utils.fromBufferToB64(original);
expect(original).toEqual(copyBefore); // unchanged
});
it("produces the same Base64 in Node vs non-Node mode", () => {
const bytes = new Uint8Array(asciiHelloWorldArray);
Utils.isNode = true;
const nodeB64 = Utils.fromBufferToB64(bytes);
Utils.isNode = false;
const browserB64 = Utils.fromBufferToB64(bytes);
expect(browserB64).toBe(nodeB64);
});
});
describe("fromB64ToArray(...)", () => {

View File

@@ -128,15 +128,52 @@ export class Utils {
return arr;
}
static fromBufferToB64(buffer: ArrayBuffer): string {
/**
* Convert binary data into a Base64 string.
*
* Overloads are provided for two categories of input:
*
* 1. ArrayBuffer
* - A raw, fixed-length chunk of memory (no element semantics).
* - Example: `const buf = new ArrayBuffer(16);`
*
* 2. ArrayBufferView
* - A *view* onto an existing buffer that gives the bytes meaning.
* - Examples: Uint8Array, Int32Array, DataView, etc.
* - Views can expose only a *window* of the underlying buffer via
* `byteOffset` and `byteLength`.
* Example:
* ```ts
* const buf = new ArrayBuffer(8);
* const full = new Uint8Array(buf); // sees all 8 bytes
* const half = new Uint8Array(buf, 4, 4); // sees only last 4 bytes
* ```
*
* Returns:
* - Base64 string for non-empty inputs,
* - null if `buffer` is `null` or `undefined`
* - empty string if `buffer` is empty (0 bytes)
*/
static fromBufferToB64(buffer: null | undefined): null;
static fromBufferToB64(buffer: ArrayBuffer): string;
static fromBufferToB64(buffer: ArrayBufferView): string;
static fromBufferToB64(buffer: ArrayBuffer | ArrayBufferView | null | undefined): string | null {
// Handle null / undefined input
if (buffer == null) {
return null;
}
const bytes: Uint8Array = Utils.normalizeToUint8Array(buffer);
// Handle empty input
if (bytes.length === 0) {
return "";
}
if (Utils.isNode) {
return Buffer.from(buffer).toString("base64");
return Buffer.from(bytes).toString("base64");
} else {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
@@ -144,6 +181,30 @@ export class Utils {
}
}
/**
* Normalizes input into a Uint8Array so we always have a uniform,
* byte-level view of the data. This avoids dealing with differences
* between ArrayBuffer (raw memory with no indexing) and other typed
* views (which may have element sizes, offsets, and lengths).
* @param buffer ArrayBuffer or ArrayBufferView (e.g. Uint8Array, DataView, etc.)
*/
private static normalizeToUint8Array(buffer: ArrayBuffer | ArrayBufferView): Uint8Array {
/**
* 1) Uint8Array: already bytes → use directly.
* 2) ArrayBuffer: wrap whole buffer.
* 3) Other ArrayBufferView (e.g., DataView, Int32Array):
* wrap the views window (byteOffset..byteOffset+byteLength).
*/
if (buffer instanceof Uint8Array) {
return buffer;
} else if (buffer instanceof ArrayBuffer) {
return new Uint8Array(buffer);
} else {
const view = buffer as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
}
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
}
@@ -314,7 +375,7 @@ export class Utils {
}
}
static getDomain(uriString: string): string {
static getDomain(uriString: string | null | undefined): string {
if (Utils.isNullOrWhitespace(uriString)) {
return null;
}
@@ -392,11 +453,11 @@ export class Utils {
};
}
static isNullOrWhitespace(str: string): boolean {
static isNullOrWhitespace(str: string | null | undefined): boolean {
return str == null || typeof str !== "string" || str.trim() === "";
}
static isNullOrEmpty(str: string | null): boolean {
static isNullOrEmpty(str: string | null | undefined): boolean {
return str == null || typeof str !== "string" || str == "";
}
@@ -418,7 +479,7 @@ export class Utils {
return (Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[]).map((k) => obj[k]);
}
static getUrl(uriString: string): URL {
static getUrl(uriString: string | undefined | null): URL {
if (this.isNullOrWhitespace(uriString)) {
return null;
}

View File

@@ -27,8 +27,8 @@ export async function getCredentialsForAutofill(
cipherId: cipher.id,
credentialId: credId,
rpId: credential.rpId,
userHandle: credential.userHandle,
userName: credential.userName,
};
userHandle: credential.userHandle!,
userName: credential.userName!,
} satisfies Fido2CredentialAutofillView;
});
}

View File

@@ -292,5 +292,100 @@ describe("DefaultSyncService", () => {
expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled();
});
});
describe("mutate 'last update time'", () => {
let mockUserState: { update: jest.Mock };
const setupMockUserState = () => {
const mockUserState = { update: jest.fn() };
jest.spyOn(stateProvider, "getUser").mockReturnValue(mockUserState as any);
return mockUserState;
};
const setupSyncScenario = (revisionDate: Date, lastSyncDate: Date) => {
jest.spyOn(apiService, "getAccountRevisionDate").mockResolvedValue(revisionDate.getTime());
jest.spyOn(sut as any, "getLastSync").mockResolvedValue(lastSyncDate);
};
const expectUpdateCallCount = (
mockUserState: { update: jest.Mock },
expectedCount: number,
) => {
if (expectedCount === 0) {
expect(mockUserState.update).not.toHaveBeenCalled();
} else {
expect(mockUserState.update).toHaveBeenCalledTimes(expectedCount);
}
};
const defaultSyncOptions = { allowThrowOnError: true, skipTokenRefresh: true };
const errorTolerantSyncOptions = { allowThrowOnError: false, skipTokenRefresh: true };
beforeEach(() => {
mockUserState = setupMockUserState();
});
it("uses the current time when a sync is forced", async () => {
// Mock the value of this observable because it's used in `syncProfile`. Without it, the test breaks.
keyConnectorService.convertAccountRequired$ = of(false);
// Baseline date/time to compare sync time to, in order to avoid needing to use some kind of fake date provider.
const beforeSync = Date.now();
// send it!
await sut.fullSync(true, defaultSyncOptions);
expectUpdateCallCount(mockUserState, 1);
// Get the first and only call to update(...)
const updateCall = mockUserState.update.mock.calls[0];
// Get the first argument to update(...) -- this will be the date callback that returns the date of the last successful sync
const dateCallback = updateCall[0];
const actualTime = dateCallback() as Date;
expect(Math.abs(actualTime.getTime() - beforeSync)).toBeLessThan(1);
});
it("updates last sync time when no sync is necessary", async () => {
const revisionDate = new Date(1);
setupSyncScenario(revisionDate, revisionDate);
const syncResult = await sut.fullSync(false, defaultSyncOptions);
// Sync should complete but return false since no sync was needed
expect(syncResult).toBe(false);
expectUpdateCallCount(mockUserState, 1);
});
it("updates last sync time when sync is successful", async () => {
setupSyncScenario(new Date(2), new Date(1));
const syncResult = await sut.fullSync(false, defaultSyncOptions);
expect(syncResult).toBe(true);
expectUpdateCallCount(mockUserState, 1);
});
describe("error scenarios", () => {
it("does not update last sync time when sync fails", async () => {
apiService.getSync.mockRejectedValue(new Error("not connected"));
const syncResult = await sut.fullSync(true, errorTolerantSyncOptions);
expect(syncResult).toBe(false);
expectUpdateCallCount(mockUserState, 0);
});
it("does not update last sync time when account revision check fails", async () => {
jest
.spyOn(apiService, "getAccountRevisionDate")
.mockRejectedValue(new Error("not connected"));
const syncResult = await sut.fullSync(false, errorTolerantSyncOptions);
expect(syncResult).toBe(false);
expectUpdateCallCount(mockUserState, 0);
});
});
});
});
});

View File

@@ -134,9 +134,11 @@ export class DefaultSyncService extends CoreSyncService {
const now = new Date();
let needsSync = false;
let needsSyncSucceeded = true;
try {
needsSync = await this.needsSyncing(forceSync);
} catch (e) {
needsSyncSucceeded = false;
if (allowThrowOnError) {
this.syncCompleted(false, userId);
throw e;
@@ -144,7 +146,9 @@ export class DefaultSyncService extends CoreSyncService {
}
if (!needsSync) {
await this.setLastSync(now, userId);
if (needsSyncSucceeded) {
await this.setLastSync(now, userId);
}
return this.syncCompleted(false, userId);
}

View File

@@ -90,14 +90,10 @@ import {
} from "../auth/models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
import { PaymentRequest } from "../billing/models/request/payment.request";
import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request";
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response";
import { PaymentResponse } from "../billing/models/response/payment.response";
import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { ClientType, DeviceType } from "../enums";
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
@@ -294,11 +290,6 @@ export class ApiService implements ApiServiceAbstraction {
return new SubscriptionResponse(r);
}
async getTaxInfo(): Promise<TaxInfoResponse> {
const r = await this.send("GET", "/accounts/tax", null, true, true);
return new TaxInfoResponse(r);
}
async putProfile(request: UpdateProfileRequest): Promise<ProfileResponse> {
const r = await this.send("PUT", "/accounts/profile", request, true, true);
return new ProfileResponse(r);
@@ -309,10 +300,6 @@ export class ApiService implements ApiServiceAbstraction {
return new ProfileResponse(r);
}
putTaxInfo(request: TaxInfoUpdateRequest): Promise<any> {
return this.send("PUT", "/accounts/tax", request, true, false);
}
async postPrelogin(request: PreloginRequest): Promise<PreloginResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const r = await this.send(
@@ -365,10 +352,6 @@ export class ApiService implements ApiServiceAbstraction {
return new PaymentResponse(r);
}
postAccountPayment(request: PaymentRequest): Promise<void> {
return this.send("POST", "/accounts/payment", request, true, false);
}
postAccountLicense(data: FormData): Promise<any> {
return this.send("POST", "/accounts/license", data, true, false);
}
@@ -429,11 +412,6 @@ export class ApiService implements ApiServiceAbstraction {
return new BillingHistoryResponse(r);
}
async getUserBillingPayment(): Promise<BillingPaymentResponse> {
const r = await this.send("GET", "/accounts/billing/payment-method", null, true, true);
return new BillingPaymentResponse(r);
}
// Cipher APIs
async getCipher(id: string): Promise<CipherResponse> {
@@ -1294,6 +1272,28 @@ export class ApiService implements ApiServiceAbstraction {
return new ListResponse(r, EventResponse);
}
async getEventsServiceAccount(
orgId: string,
id: string,
start: string,
end: string,
token: string,
): Promise<ListResponse<EventResponse>> {
const r = await this.send(
"GET",
this.addEventParameters(
"/organization/" + orgId + "/service-account/" + id + "/events",
start,
end,
token,
),
null,
true,
true,
);
return new ListResponse(r, EventResponse);
}
async getEventsProject(
orgId: string,
id: string,

View File

@@ -1,7 +1,6 @@
import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService {
@@ -10,5 +9,4 @@ export abstract class CipherArchiveService {
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract canInteract(cipher: CipherView): Promise<boolean>;
}

View File

@@ -30,6 +30,7 @@ export abstract class SearchService {
ciphers: C[],
query: string,
deleted?: boolean,
archived?: boolean,
): C[];
abstract searchSends(sends: SendView[], query: string): SendView[];
}

View File

@@ -47,6 +47,7 @@ export class Attachment extends Domain {
): Promise<AttachmentView> {
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
// @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now.
new AttachmentView(this),
["fileName"],
orgId,

View File

@@ -63,7 +63,6 @@ describe("Card", () => {
expect(view).toEqual({
_brand: "brand",
_number: "number",
_subTitle: null,
cardholderName: "cardHolder",
code: "code",
expMonth: "expMonth",

View File

@@ -161,6 +161,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
await this.decryptObj<Cipher, CipherView>(
this,
// @ts-expect-error Ciphers have optional Ids which are getting swallowed by the ViewEncryptableKeys type
// The ViewEncryptableKeys type should be fixed to allow for optional Ids, but is out of scope for now.
model,
["name", "notes"],
this.organizationId,
@@ -349,7 +351,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
*/
toSdkCipher(): SdkCipher {
const sdkCipher: SdkCipher = {
id: asUuid(this.id),
id: this.id ? asUuid(this.id) : undefined,
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
folderId: this.folderId ? asUuid(this.folderId) : undefined,
collectionIds: this.collectionIds ? this.collectionIds.map(asUuid) : ([] as any),

View File

@@ -56,6 +56,7 @@ export class Fido2Credential extends Domain {
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
// @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now.
new Fido2CredentialView(),
[
"credentialId",

View File

@@ -39,6 +39,7 @@ export class Field extends Domain {
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj<Field, FieldView>(
this,
// @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now.
new FieldView(this),
["name", "value"],
orgId,

View File

@@ -112,7 +112,6 @@ describe("Identity", () => {
expect(view).toEqual({
_firstName: "mockFirstName",
_lastName: "mockLastName",
_subTitle: null,
address1: "mockAddress1",
address2: "mockAddress2",
address3: "mockAddress3",

View File

@@ -56,10 +56,6 @@ describe("LoginUri", () => {
const view = await loginUri.decrypt(null);
expect(view).toEqual({
_canLaunch: null,
_domain: null,
_host: null,
_hostname: null,
_uri: "uri",
match: 3,
});

View File

@@ -2,7 +2,7 @@ import { MockProxy, mock } from "jest-mock-extended";
import { mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { LoginData } from "../../models/data/login.data";
import { Login } from "../../models/domain/login";
import { LoginUri } from "../../models/domain/login-uri";
@@ -82,12 +82,7 @@ describe("Login DTO", () => {
totp: "encrypted totp",
uris: [
{
match: null as UriMatchStrategySetting,
_uri: "decrypted uri",
_domain: null as string,
_hostname: null as string,
_host: null as string,
_canLaunch: null as boolean,
},
],
autofillOnPageLoad: true,

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal";
@@ -10,12 +8,12 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
import { Attachment } from "../domain/attachment";
export class AttachmentView implements View {
id: string = null;
url: string = null;
size: string = null;
sizeName: string = null;
fileName: string = null;
key: SymmetricCryptoKey = null;
id?: string;
url?: string;
size?: string;
sizeName?: string;
fileName?: string;
key?: SymmetricCryptoKey;
/**
* The SDK returns an encrypted key for the attachment.
*/
@@ -35,7 +33,7 @@ export class AttachmentView implements View {
get fileSize(): number {
try {
if (this.size != null) {
return parseInt(this.size, null);
return parseInt(this.size);
}
} catch {
// Invalid file size.
@@ -71,7 +69,7 @@ export class AttachmentView implements View {
fileName: this.fileName,
key: this.encryptedKey?.toSdk(),
// TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete
decryptedKey: this.key ? this.key.toBase64() : null,
decryptedKey: this.key ? this.key.toBase64() : undefined,
};
}
@@ -84,13 +82,13 @@ export class AttachmentView implements View {
}
const view = new AttachmentView();
view.id = obj.id ?? null;
view.url = obj.url ?? null;
view.size = obj.size ?? null;
view.sizeName = obj.sizeName ?? null;
view.fileName = obj.fileName ?? null;
view.id = obj.id;
view.url = obj.url;
view.size = obj.size;
view.sizeName = obj.sizeName;
view.fileName = obj.fileName;
// TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete
view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : null;
view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined;
view.encryptedKey = obj.key ? new EncString(obj.key) : undefined;
return view;

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { CardView as SdkCardView } from "@bitwarden/sdk-internal";
@@ -12,45 +10,45 @@ import { ItemView } from "./item.view";
export class CardView extends ItemView implements SdkCardView {
@linkedFieldOption(LinkedId.CardholderName, { sortPosition: 0 })
cardholderName: string = null;
cardholderName: string | undefined;
@linkedFieldOption(LinkedId.ExpMonth, { sortPosition: 3, i18nKey: "expirationMonth" })
expMonth: string = null;
expMonth: string | undefined;
@linkedFieldOption(LinkedId.ExpYear, { sortPosition: 4, i18nKey: "expirationYear" })
expYear: string = null;
expYear: string | undefined;
@linkedFieldOption(LinkedId.Code, { sortPosition: 5, i18nKey: "securityCode" })
code: string = null;
code: string | undefined;
private _brand: string = null;
private _number: string = null;
private _subTitle: string = null;
private _brand?: string;
private _number?: string;
private _subTitle?: string;
get maskedCode(): string {
return this.code != null ? "•".repeat(this.code.length) : null;
get maskedCode(): string | undefined {
return this.code != null ? "•".repeat(this.code.length) : undefined;
}
get maskedNumber(): string {
return this.number != null ? "•".repeat(this.number.length) : null;
get maskedNumber(): string | undefined {
return this.number != null ? "•".repeat(this.number.length) : undefined;
}
@linkedFieldOption(LinkedId.Brand, { sortPosition: 2 })
get brand(): string {
get brand(): string | undefined {
return this._brand;
}
set brand(value: string) {
set brand(value: string | undefined) {
this._brand = value;
this._subTitle = null;
this._subTitle = undefined;
}
@linkedFieldOption(LinkedId.Number, { sortPosition: 1 })
get number(): string {
get number(): string | undefined {
return this._number;
}
set number(value: string) {
set number(value: string | undefined) {
this._number = value;
this._subTitle = null;
this._subTitle = undefined;
}
get subTitle(): string {
get subTitle(): string | undefined {
if (this._subTitle == null) {
this._subTitle = this.brand;
if (this.number != null && this.number.length >= 4) {
@@ -69,11 +67,11 @@ export class CardView extends ItemView implements SdkCardView {
return this._subTitle;
}
get expiration(): string {
const normalizedYear = normalizeExpiryYearFormat(this.expYear);
get expiration(): string | undefined {
const normalizedYear = this.expYear ? normalizeExpiryYearFormat(this.expYear) : undefined;
if (!this.expMonth && !normalizedYear) {
return null;
return undefined;
}
let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__";
@@ -82,14 +80,14 @@ export class CardView extends ItemView implements SdkCardView {
return exp;
}
static fromJSON(obj: Partial<Jsonify<CardView>>): CardView {
static fromJSON(obj: Partial<Jsonify<CardView>> | undefined): CardView {
return Object.assign(new CardView(), obj);
}
// ref https://stackoverflow.com/a/5911300
static getCardBrandByPatterns(cardNum: string): string {
static getCardBrandByPatterns(cardNum: string | undefined | null): string | undefined {
if (cardNum == null || typeof cardNum !== "string" || cardNum.trim() === "") {
return null;
return undefined;
}
// Visa
@@ -146,25 +144,21 @@ export class CardView extends ItemView implements SdkCardView {
return "Visa";
}
return null;
return undefined;
}
/**
* Converts an SDK CardView to a CardView.
*/
static fromSdkCardView(obj: SdkCardView): CardView | undefined {
if (obj == null) {
return undefined;
}
static fromSdkCardView(obj: SdkCardView): CardView {
const cardView = new CardView();
cardView.cardholderName = obj.cardholderName ?? null;
cardView.brand = obj.brand ?? null;
cardView.number = obj.number ?? null;
cardView.expMonth = obj.expMonth ?? null;
cardView.expYear = obj.expYear ?? null;
cardView.code = obj.code ?? null;
cardView.cardholderName = obj.cardholderName;
cardView.brand = obj.brand;
cardView.number = obj.number;
cardView.expMonth = obj.expMonth;
cardView.expYear = obj.expYear;
cardView.code = obj.code;
return cardView;
}

View File

@@ -180,15 +180,12 @@ describe("CipherView", () => {
folderId: "folderId",
collectionIds: ["collectionId"],
name: "name",
notes: null,
type: CipherType.Login,
favorite: true,
edit: true,
reprompt: CipherRepromptType.None,
organizationUseTotp: false,
viewPassword: true,
localData: undefined,
permissions: undefined,
attachments: [
{
id: "attachmentId",
@@ -224,7 +221,6 @@ describe("CipherView", () => {
passwordHistory: [],
creationDate: new Date("2022-01-01T12:00:00.000Z"),
revisionDate: new Date("2022-01-02T12:00:00.000Z"),
deletedDate: null,
});
});
});
@@ -283,18 +279,12 @@ describe("CipherView", () => {
restore: true,
delete: true,
},
deletedDate: undefined,
creationDate: "2022-01-02T12:00:00.000Z",
revisionDate: "2022-01-02T12:00:00.000Z",
attachments: [],
passwordHistory: [],
login: undefined,
identity: undefined,
card: undefined,
secureNote: undefined,
sshKey: undefined,
fields: [],
} as SdkCipherView);
});
});
});
});

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { ItemView } from "@bitwarden/common/vault/models/view/item.view";
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
import { View } from "../../../models/view/view";
@@ -26,18 +25,18 @@ import { SshKeyView } from "./ssh-key.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
id: string = null;
organizationId: string | undefined = null;
folderId: string = null;
name: string = null;
notes: string = null;
type: CipherType = null;
id: string = "";
organizationId?: string;
folderId?: string;
name: string = "";
notes?: string;
type: CipherType = CipherType.Login;
favorite = false;
organizationUseTotp = false;
permissions: CipherPermissionsApi = new CipherPermissionsApi();
permissions?: CipherPermissionsApi = new CipherPermissionsApi();
edit = false;
viewPassword = true;
localData: LocalData;
localData?: LocalData;
login = new LoginView();
identity = new IdentityView();
card = new CardView();
@@ -46,11 +45,11 @@ export class CipherView implements View, InitializerMetadata {
attachments: AttachmentView[] = [];
fields: FieldView[] = [];
passwordHistory: PasswordHistoryView[] = [];
collectionIds: string[] = null;
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date | null = null;
archivedDate: Date | null = null;
collectionIds: string[] = [];
revisionDate: Date;
creationDate: Date;
deletedDate?: Date;
archivedDate?: Date;
reprompt: CipherRepromptType = CipherRepromptType.None;
// We need a copy of the encrypted key so we can pass it to
// the SdkCipherView during encryption
@@ -63,6 +62,7 @@ export class CipherView implements View, InitializerMetadata {
constructor(c?: Cipher) {
if (!c) {
this.creationDate = this.revisionDate = new Date();
return;
}
@@ -86,7 +86,7 @@ export class CipherView implements View, InitializerMetadata {
this.key = c.key;
}
private get item() {
private get item(): ItemView | undefined {
switch (this.type) {
case CipherType.Login:
return this.login;
@@ -102,10 +102,10 @@ export class CipherView implements View, InitializerMetadata {
break;
}
return null;
return undefined;
}
get subTitle(): string {
get subTitle(): string | undefined {
return this.item?.subTitle;
}
@@ -114,7 +114,7 @@ export class CipherView implements View, InitializerMetadata {
}
get hasAttachments(): boolean {
return this.attachments && this.attachments.length > 0;
return !!this.attachments && this.attachments.length > 0;
}
get hasOldAttachments(): boolean {
@@ -132,11 +132,11 @@ export class CipherView implements View, InitializerMetadata {
return this.fields && this.fields.length > 0;
}
get passwordRevisionDisplayDate(): Date {
get passwordRevisionDisplayDate(): Date | undefined {
if (this.type !== CipherType.Login || this.login == null) {
return null;
return undefined;
} else if (this.login.password == null || this.login.password === "") {
return null;
return undefined;
}
return this.login.passwordRevisionDate;
}
@@ -170,23 +170,17 @@ export class CipherView implements View, InitializerMetadata {
* Determines if the cipher can be launched in a new browser tab.
*/
get canLaunch(): boolean {
return this.type === CipherType.Login && this.login.canLaunch;
return this.type === CipherType.Login && this.login!.canLaunch;
}
linkedFieldValue(id: LinkedIdType) {
const linkedFieldOption = this.linkedFieldOptions?.get(id);
if (linkedFieldOption == null) {
return null;
const item = this.item;
if (linkedFieldOption == null || item == null) {
return undefined;
}
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const item = this.item;
return this.item[linkedFieldOption.propertyKey as keyof typeof item];
}
linkedFieldI18nKey(id: LinkedIdType): string {
return this.linkedFieldOptions.get(id)?.i18nKey;
return item[linkedFieldOption.propertyKey as keyof typeof item];
}
// This is used as a marker to indicate that the cipher view object still has its prototype
@@ -194,23 +188,31 @@ export class CipherView implements View, InitializerMetadata {
return this;
}
static fromJSON(obj: Partial<DeepJsonify<CipherView>>): CipherView {
static fromJSON(obj: Partial<DeepJsonify<CipherView>>): CipherView | null {
if (obj == null) {
return null;
}
const view = new CipherView();
const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate);
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
const permissions = CipherPermissionsApi.fromJSON(obj.permissions);
let key: EncString | undefined;
view.type = obj.type ?? CipherType.Login;
view.id = obj.id ?? "";
view.name = obj.name ?? "";
if (obj.creationDate) {
view.creationDate = new Date(obj.creationDate);
}
if (obj.revisionDate) {
view.revisionDate = new Date(obj.revisionDate);
}
view.deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate);
view.archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate);
view.attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a)) ?? [];
view.fields = obj.fields?.map((f: any) => FieldView.fromJSON(f)) ?? [];
view.passwordHistory =
obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph)) ?? [];
view.permissions = obj.permissions ? CipherPermissionsApi.fromJSON(obj.permissions) : undefined;
if (obj.key != null) {
let key: EncString | undefined;
if (typeof obj.key === "string") {
// If the key is a string, we need to parse it as EncString
key = EncString.fromJSON(obj.key);
@@ -218,20 +220,9 @@ export class CipherView implements View, InitializerMetadata {
// If the key is already an EncString instance, we can use it directly
key = obj.key;
}
view.key = key;
}
Object.assign(view, obj, {
creationDate: creationDate,
revisionDate: revisionDate,
deletedDate: deletedDate,
archivedDate: archivedDate,
attachments: attachments,
fields: fields,
passwordHistory: passwordHistory,
permissions: permissions,
key: key,
});
switch (obj.type) {
case CipherType.Card:
view.card = CardView.fromJSON(obj.card);
@@ -264,46 +255,54 @@ export class CipherView implements View, InitializerMetadata {
}
const cipherView = new CipherView();
cipherView.id = uuidAsString(obj.id) ?? null;
cipherView.organizationId = uuidAsString(obj.organizationId) ?? null;
cipherView.folderId = uuidAsString(obj.folderId) ?? null;
cipherView.id = uuidAsString(obj.id);
cipherView.organizationId = uuidAsString(obj.organizationId);
cipherView.folderId = uuidAsString(obj.folderId);
cipherView.name = obj.name;
cipherView.notes = obj.notes ?? null;
cipherView.notes = obj.notes;
cipherView.type = obj.type;
cipherView.favorite = obj.favorite;
cipherView.organizationUseTotp = obj.organizationUseTotp;
cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions);
cipherView.permissions = obj.permissions
? CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions)
: undefined;
cipherView.edit = obj.edit;
cipherView.viewPassword = obj.viewPassword;
cipherView.localData = fromSdkLocalData(obj.localData);
cipherView.attachments =
obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? [];
cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? [];
obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? [];
cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? [];
cipherView.passwordHistory =
obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? [];
obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? [];
cipherView.collectionIds = obj.collectionIds?.map((i) => uuidAsString(i)) ?? [];
cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
cipherView.archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate);
cipherView.revisionDate = new Date(obj.revisionDate);
cipherView.creationDate = new Date(obj.creationDate);
cipherView.deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate);
cipherView.archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate);
cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None;
cipherView.key = EncString.fromJSON(obj.key);
cipherView.key = obj.key ? EncString.fromJSON(obj.key) : undefined;
switch (obj.type) {
case CipherType.Card:
cipherView.card = CardView.fromSdkCardView(obj.card);
cipherView.card = obj.card ? CardView.fromSdkCardView(obj.card) : new CardView();
break;
case CipherType.Identity:
cipherView.identity = IdentityView.fromSdkIdentityView(obj.identity);
cipherView.identity = obj.identity
? IdentityView.fromSdkIdentityView(obj.identity)
: new IdentityView();
break;
case CipherType.Login:
cipherView.login = LoginView.fromSdkLoginView(obj.login);
cipherView.login = obj.login ? LoginView.fromSdkLoginView(obj.login) : new LoginView();
break;
case CipherType.SecureNote:
cipherView.secureNote = SecureNoteView.fromSdkSecureNoteView(obj.secureNote);
cipherView.secureNote = obj.secureNote
? SecureNoteView.fromSdkSecureNoteView(obj.secureNote)
: new SecureNoteView();
break;
case CipherType.SshKey:
cipherView.sshKey = SshKeyView.fromSdkSshKeyView(obj.sshKey);
cipherView.sshKey = obj.sshKey
? SshKeyView.fromSdkSshKeyView(obj.sshKey)
: new SshKeyView();
break;
default:
break;
@@ -325,11 +324,11 @@ export class CipherView implements View, InitializerMetadata {
name: this.name ?? "",
notes: this.notes,
type: this.type ?? CipherType.Login,
favorite: this.favorite,
organizationUseTotp: this.organizationUseTotp,
favorite: this.favorite ?? false,
organizationUseTotp: this.organizationUseTotp ?? false,
permissions: this.permissions?.toSdkCipherPermissions(),
edit: this.edit,
viewPassword: this.viewPassword,
edit: this.edit ?? true,
viewPassword: this.viewPassword ?? true,
localData: toSdkLocalData(this.localData),
attachments: this.attachments?.map((a) => a.toSdkAttachmentView()),
fields: this.fields?.map((f) => f.toSdkFieldView()),
@@ -354,19 +353,19 @@ export class CipherView implements View, InitializerMetadata {
switch (this.type) {
case CipherType.Card:
sdkCipherView.card = this.card.toSdkCardView();
sdkCipherView.card = this.card?.toSdkCardView();
break;
case CipherType.Identity:
sdkCipherView.identity = this.identity.toSdkIdentityView();
sdkCipherView.identity = this.identity?.toSdkIdentityView();
break;
case CipherType.Login:
sdkCipherView.login = this.login.toSdkLoginView();
sdkCipherView.login = this.login?.toSdkLoginView();
break;
case CipherType.SecureNote:
sdkCipherView.secureNote = this.secureNote.toSdkSecureNoteView();
sdkCipherView.secureNote = this.secureNote?.toSdkSecureNoteView();
break;
case CipherType.SshKey:
sdkCipherView.sshKey = this.sshKey.toSdkSshKeyView();
sdkCipherView.sshKey = this.sshKey?.toSdkSshKeyView();
break;
default:
break;

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import {
@@ -10,21 +8,55 @@ import {
import { ItemView } from "./item.view";
export class Fido2CredentialView extends ItemView {
credentialId: string;
keyType: "public-key";
keyAlgorithm: "ECDSA";
keyCurve: "P-256";
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: number;
rpName: string;
userDisplayName: string;
discoverable: boolean;
creationDate: Date = null;
credentialId!: string;
keyType!: "public-key";
keyAlgorithm!: "ECDSA";
keyCurve!: "P-256";
keyValue!: string;
rpId!: string;
userHandle?: string;
userName?: string;
counter!: number;
rpName?: string;
userDisplayName?: string;
discoverable: boolean = false;
creationDate!: Date;
get subTitle(): string {
constructor(f?: {
credentialId: string;
keyType: "public-key";
keyAlgorithm: "ECDSA";
keyCurve: "P-256";
keyValue: string;
rpId: string;
userHandle?: string;
userName?: string;
counter: number;
rpName?: string;
userDisplayName?: string;
discoverable?: boolean;
creationDate: Date;
}) {
super();
if (f == null) {
return;
}
this.credentialId = f.credentialId;
this.keyType = f.keyType;
this.keyAlgorithm = f.keyAlgorithm;
this.keyCurve = f.keyCurve;
this.keyValue = f.keyValue;
this.rpId = f.rpId;
this.userHandle = f.userHandle;
this.userName = f.userName;
this.counter = f.counter;
this.rpName = f.rpName;
this.userDisplayName = f.userDisplayName;
this.discoverable = f.discoverable ?? false;
this.creationDate = f.creationDate;
}
get subTitle(): string | undefined {
return this.userDisplayName;
}
@@ -43,21 +75,21 @@ export class Fido2CredentialView extends ItemView {
return undefined;
}
const view = new Fido2CredentialView();
view.credentialId = obj.credentialId;
view.keyType = obj.keyType as "public-key";
view.keyAlgorithm = obj.keyAlgorithm as "ECDSA";
view.keyCurve = obj.keyCurve as "P-256";
view.rpId = obj.rpId;
view.userHandle = obj.userHandle;
view.userName = obj.userName;
view.counter = parseInt(obj.counter);
view.rpName = obj.rpName;
view.userDisplayName = obj.userDisplayName;
view.discoverable = obj.discoverable?.toLowerCase() === "true" ? true : false;
view.creationDate = obj.creationDate ? new Date(obj.creationDate) : null;
return view;
return new Fido2CredentialView({
credentialId: obj.credentialId,
keyType: obj.keyType as "public-key",
keyAlgorithm: obj.keyAlgorithm as "ECDSA",
keyCurve: obj.keyCurve as "P-256",
keyValue: obj.keyValue,
rpId: obj.rpId,
userHandle: obj.userHandle,
userName: obj.userName,
counter: parseInt(obj.counter),
rpName: obj.rpName,
userDisplayName: obj.userDisplayName,
discoverable: obj.discoverable?.toLowerCase() === "true",
creationDate: new Date(obj.creationDate),
});
}
toSdkFido2CredentialFullView(): Fido2CredentialFullView {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { FieldView as SdkFieldView, FieldType as SdkFieldType } from "@bitwarden/sdk-internal";
@@ -9,13 +7,13 @@ import { FieldType, LinkedIdType } from "../../enums";
import { Field } from "../domain/field";
export class FieldView implements View {
name: string = null;
value: string = null;
type: FieldType = null;
name?: string;
value?: string;
type: FieldType = FieldType.Text;
newField = false; // Marks if the field is new and hasn't been saved
showValue = false;
showCount = false;
linkedId: LinkedIdType = null;
linkedId?: LinkedIdType;
constructor(f?: Field) {
if (!f) {
@@ -26,8 +24,8 @@ export class FieldView implements View {
this.linkedId = f.linkedId;
}
get maskedValue(): string {
return this.value != null ? "••••••••" : null;
get maskedValue(): string | undefined {
return this.value != null ? "••••••••" : undefined;
}
static fromJSON(obj: Partial<Jsonify<FieldView>>): FieldView {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { IdentityView as SdkIdentityView } from "@bitwarden/sdk-internal";
@@ -12,65 +10,65 @@ import { ItemView } from "./item.view";
export class IdentityView extends ItemView implements SdkIdentityView {
@linkedFieldOption(LinkedId.Title, { sortPosition: 0 })
title: string = null;
title: string | undefined;
@linkedFieldOption(LinkedId.MiddleName, { sortPosition: 2 })
middleName: string = null;
middleName: string | undefined;
@linkedFieldOption(LinkedId.Address1, { sortPosition: 12 })
address1: string = null;
address1: string | undefined;
@linkedFieldOption(LinkedId.Address2, { sortPosition: 13 })
address2: string = null;
address2: string | undefined;
@linkedFieldOption(LinkedId.Address3, { sortPosition: 14 })
address3: string = null;
address3: string | undefined;
@linkedFieldOption(LinkedId.City, { sortPosition: 15, i18nKey: "cityTown" })
city: string = null;
city: string | undefined;
@linkedFieldOption(LinkedId.State, { sortPosition: 16, i18nKey: "stateProvince" })
state: string = null;
state: string | undefined;
@linkedFieldOption(LinkedId.PostalCode, { sortPosition: 17, i18nKey: "zipPostalCode" })
postalCode: string = null;
postalCode: string | undefined;
@linkedFieldOption(LinkedId.Country, { sortPosition: 18 })
country: string = null;
country: string | undefined;
@linkedFieldOption(LinkedId.Company, { sortPosition: 6 })
company: string = null;
company: string | undefined;
@linkedFieldOption(LinkedId.Email, { sortPosition: 10 })
email: string = null;
email: string | undefined;
@linkedFieldOption(LinkedId.Phone, { sortPosition: 11 })
phone: string = null;
phone: string | undefined;
@linkedFieldOption(LinkedId.Ssn, { sortPosition: 7 })
ssn: string = null;
ssn: string | undefined;
@linkedFieldOption(LinkedId.Username, { sortPosition: 5 })
username: string = null;
username: string | undefined;
@linkedFieldOption(LinkedId.PassportNumber, { sortPosition: 8 })
passportNumber: string = null;
passportNumber: string | undefined;
@linkedFieldOption(LinkedId.LicenseNumber, { sortPosition: 9 })
licenseNumber: string = null;
licenseNumber: string | undefined;
private _firstName: string = null;
private _lastName: string = null;
private _subTitle: string = null;
private _firstName: string | undefined;
private _lastName: string | undefined;
private _subTitle: string | undefined;
constructor() {
super();
}
@linkedFieldOption(LinkedId.FirstName, { sortPosition: 1 })
get firstName(): string {
get firstName(): string | undefined {
return this._firstName;
}
set firstName(value: string) {
set firstName(value: string | undefined) {
this._firstName = value;
this._subTitle = null;
this._subTitle = undefined;
}
@linkedFieldOption(LinkedId.LastName, { sortPosition: 4 })
get lastName(): string {
get lastName(): string | undefined {
return this._lastName;
}
set lastName(value: string) {
set lastName(value: string | undefined) {
this._lastName = value;
this._subTitle = null;
this._subTitle = undefined;
}
get subTitle(): string {
get subTitle(): string | undefined {
if (this._subTitle == null && (this.firstName != null || this.lastName != null)) {
this._subTitle = "";
if (this.firstName != null) {
@@ -88,7 +86,7 @@ export class IdentityView extends ItemView implements SdkIdentityView {
}
@linkedFieldOption(LinkedId.FullName, { sortPosition: 3 })
get fullName(): string {
get fullName(): string | undefined {
if (
this.title != null ||
this.firstName != null ||
@@ -111,11 +109,11 @@ export class IdentityView extends ItemView implements SdkIdentityView {
return name.trim();
}
return null;
return undefined;
}
get fullAddress(): string {
let address = this.address1;
get fullAddress(): string | undefined {
let address = this.address1 ?? "";
if (!Utils.isNullOrWhitespace(this.address2)) {
if (!Utils.isNullOrWhitespace(address)) {
address += ", ";
@@ -131,9 +129,9 @@ export class IdentityView extends ItemView implements SdkIdentityView {
return address;
}
get fullAddressPart2(): string {
get fullAddressPart2(): string | undefined {
if (this.city == null && this.state == null && this.postalCode == null) {
return null;
return undefined;
}
const city = this.city || "-";
const state = this.state;
@@ -146,7 +144,7 @@ export class IdentityView extends ItemView implements SdkIdentityView {
return addressPart2;
}
get fullAddressForCopy(): string {
get fullAddressForCopy(): string | undefined {
let address = this.fullAddress;
if (this.city != null || this.state != null || this.postalCode != null) {
address += "\n" + this.fullAddressPart2;
@@ -157,38 +155,34 @@ export class IdentityView extends ItemView implements SdkIdentityView {
return address;
}
static fromJSON(obj: Partial<Jsonify<IdentityView>>): IdentityView {
static fromJSON(obj: Partial<Jsonify<IdentityView>> | undefined): IdentityView {
return Object.assign(new IdentityView(), obj);
}
/**
* Converts the SDK IdentityView to an IdentityView.
*/
static fromSdkIdentityView(obj: SdkIdentityView): IdentityView | undefined {
if (obj == null) {
return undefined;
}
static fromSdkIdentityView(obj: SdkIdentityView): IdentityView {
const identityView = new IdentityView();
identityView.title = obj.title ?? null;
identityView.firstName = obj.firstName ?? null;
identityView.middleName = obj.middleName ?? null;
identityView.lastName = obj.lastName ?? null;
identityView.address1 = obj.address1 ?? null;
identityView.address2 = obj.address2 ?? null;
identityView.address3 = obj.address3 ?? null;
identityView.city = obj.city ?? null;
identityView.state = obj.state ?? null;
identityView.postalCode = obj.postalCode ?? null;
identityView.country = obj.country ?? null;
identityView.company = obj.company ?? null;
identityView.email = obj.email ?? null;
identityView.phone = obj.phone ?? null;
identityView.ssn = obj.ssn ?? null;
identityView.username = obj.username ?? null;
identityView.passportNumber = obj.passportNumber ?? null;
identityView.licenseNumber = obj.licenseNumber ?? null;
identityView.title = obj.title;
identityView.firstName = obj.firstName;
identityView.middleName = obj.middleName;
identityView.lastName = obj.lastName;
identityView.address1 = obj.address1;
identityView.address2 = obj.address2;
identityView.address3 = obj.address3;
identityView.city = obj.city;
identityView.state = obj.state;
identityView.postalCode = obj.postalCode;
identityView.country = obj.country;
identityView.company = obj.company;
identityView.email = obj.email;
identityView.phone = obj.phone;
identityView.ssn = obj.ssn;
identityView.username = obj.username;
identityView.passportNumber = obj.passportNumber;
identityView.licenseNumber = obj.licenseNumber;
return identityView;
}

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { View } from "../../../models/view/view";
import { LinkedMetadata } from "../../linked-field-option.decorator";
export abstract class ItemView implements View {
linkedFieldOptions: Map<number, LinkedMetadata>;
abstract get subTitle(): string;
linkedFieldOptions?: Map<number, LinkedMetadata>;
abstract get subTitle(): string | undefined;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { LoginUriView as SdkLoginUriView } from "@bitwarden/sdk-internal";
@@ -11,13 +9,13 @@ import { Utils } from "../../../platform/misc/utils";
import { LoginUri } from "../domain/login-uri";
export class LoginUriView implements View {
match: UriMatchStrategySetting = null;
match?: UriMatchStrategySetting;
private _uri: string = null;
private _domain: string = null;
private _hostname: string = null;
private _host: string = null;
private _canLaunch: boolean = null;
private _uri?: string;
private _domain?: string;
private _hostname?: string;
private _host?: string;
private _canLaunch?: boolean;
constructor(u?: LoginUri) {
if (!u) {
@@ -27,59 +25,59 @@ export class LoginUriView implements View {
this.match = u.match;
}
get uri(): string {
get uri(): string | undefined {
return this._uri;
}
set uri(value: string) {
set uri(value: string | undefined) {
this._uri = value;
this._domain = null;
this._canLaunch = null;
this._domain = undefined;
this._canLaunch = undefined;
}
get domain(): string {
get domain(): string | undefined {
if (this._domain == null && this.uri != null) {
this._domain = Utils.getDomain(this.uri);
if (this._domain === "") {
this._domain = null;
this._domain = undefined;
}
}
return this._domain;
}
get hostname(): string {
get hostname(): string | undefined {
if (this.match === UriMatchStrategy.RegularExpression) {
return null;
return undefined;
}
if (this._hostname == null && this.uri != null) {
this._hostname = Utils.getHostname(this.uri);
if (this._hostname === "") {
this._hostname = null;
this._hostname = undefined;
}
}
return this._hostname;
}
get host(): string {
get host(): string | undefined {
if (this.match === UriMatchStrategy.RegularExpression) {
return null;
return undefined;
}
if (this._host == null && this.uri != null) {
this._host = Utils.getHost(this.uri);
if (this._host === "") {
this._host = null;
this._host = undefined;
}
}
return this._host;
}
get hostnameOrUri(): string {
get hostnameOrUri(): string | undefined {
return this.hostname != null ? this.hostname : this.uri;
}
get hostOrUri(): string {
get hostOrUri(): string | undefined {
return this.host != null ? this.host : this.uri;
}
@@ -104,7 +102,10 @@ export class LoginUriView implements View {
return this._canLaunch;
}
get launchUri(): string {
get launchUri(): string | undefined {
if (this.uri == null) {
return undefined;
}
return this.uri.indexOf("://") < 0 && !Utils.isNullOrWhitespace(Utils.getDomain(this.uri))
? "http://" + this.uri
: this.uri;
@@ -141,7 +142,7 @@ export class LoginUriView implements View {
matchesUri(
targetUri: string,
equivalentDomains: Set<string>,
defaultUriMatch: UriMatchStrategySetting = null,
defaultUriMatch?: UriMatchStrategySetting,
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
overrideNeverMatchStrategy?: true,
): boolean {
@@ -198,7 +199,7 @@ export class LoginUriView implements View {
if (Utils.DomainMatchBlacklist.has(this.domain)) {
const domainUrlHost = Utils.getHost(targetUri);
return !Utils.DomainMatchBlacklist.get(this.domain).has(domainUrlHost);
return !Utils.DomainMatchBlacklist.get(this.domain)!.has(domainUrlHost);
}
return true;

Some files were not shown because too many files have changed in this diff Show More