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

Merge remote-tracking branch 'origin' into auth/pm-23620/auth-request-answering-service

This commit is contained in:
Patrick Pimentel
2025-08-20 17:05:56 -04:00
745 changed files with 21182 additions and 13541 deletions

View File

@@ -1,84 +0,0 @@
<ng-container
*ngIf="{
selectedRegion: selectedRegion$ | async,
} as data"
>
<div class="tw-text-sm tw-text-muted tw-leading-7 tw-font-normal tw-pl-4">
{{ "accessing" | i18n }}:
<button
type="button"
(click)="toggle(null)"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
>
<span class="tw-text-primary-600 tw-text-sm tw-font-semibold">
<ng-container *ngIf="data.selectedRegion; else fallback">
{{ data.selectedRegion.domain }}
</ng-container>
<ng-template #fallback>
{{ "selfHostedServer" | i18n }}
</ng-template>
</span>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="isOpen = false"
(detach)="close()"
>
<div class="tw-box-content">
<div
class="tw-bg-background tw-w-full tw-shadow-md tw-p-2 tw-rounded-md"
data-testid="environment-selector-dialog"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<ng-container *ngFor="let region of availableRegions; let i = index">
<button
type="button"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-pr-2 tw-rounded-md"
(click)="toggle(region.key)"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
[attr.data-testid]="'environment-selector-dialog-item-' + i"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
></i>
<span>{{ region.domain }}</span>
</button>
<br />
</ng-container>
<button
type="button"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-pr-2 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-rounded-md"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
data-testid="environment-selector-dialog-item-self-hosted"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
></i>
<span>{{ "selfHostedServer" | i18n }}</span>
</button>
</div>
</div>
</ng-template>
</ng-container>

View File

@@ -1,135 +0,0 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, map, Subject, takeUntil } from "rxjs";
// 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import {
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
];
export const DesktopDefaultOverlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
];
export interface EnvironmentSelectorRouteData {
overlayPosition?: ConnectedPosition[];
}
@Component({
selector: "environment-selector",
templateUrl: "environment-selector.component.html",
animations: [
trigger("transformPanel", [
state(
"void",
style({
opacity: 0,
}),
),
transition(
"void => open",
animate(
"100ms linear",
style({
opacity: 1,
}),
),
),
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
standalone: false,
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();
@Input() overlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
},
];
protected isOpen = false;
protected ServerEnvironmentType = Region;
protected availableRegions = this.environmentService.availableRegions();
protected selectedRegion$: Observable<RegionConfig | undefined> =
this.environmentService.environment$.pipe(
map((e) => e.getRegion()),
map((r) => this.availableRegions.find((ar) => ar.key === r)),
);
private destroy$ = new Subject<void>();
constructor(
protected environmentService: EnvironmentService,
private route: ActivatedRoute,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngOnInit() {
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
if (data && data["overlayPosition"]) {
this.overlayPosition = data["overlayPosition"];
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async toggle(option: Region) {
this.isOpen = !this.isOpen;
if (option === null) {
return;
}
/**
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (option === Region.SelfHosted) {
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
if (dialogResult) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
}
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
return;
}
await this.environmentService.setEnvironment(option);
}
close() {
this.isOpen = false;
}
}

View File

@@ -6,8 +6,7 @@
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(click)="answerAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
@@ -21,7 +20,7 @@
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<br />
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>

View File

@@ -1,15 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
import { BadgeModule, ItemModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in an item list view */
@Component({
@@ -20,24 +16,12 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementItemGroupComponent {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
constructor(private dialogService: DialogService) {}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -1,4 +1,4 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="64">
<!-- Table Header -->
<ng-container header>
<th
@@ -6,7 +6,6 @@
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
@@ -17,24 +16,17 @@
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<td bitCell class="tw-flex tw-gap-2 tw-items-center tw-h-16">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
@if (device.pendingAuthRequest) {
<a
bitLink
href="#"
appStopClick
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
<a bitLink href="#" appStopClick (click)="answerAuthRequest(device.pendingAuthRequest)">
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
<br />
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">

View File

@@ -1,6 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
@@ -8,16 +7,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import {
BadgeModule,
ButtonModule,
DialogService,
LinkModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in a sortable table view */
@Component({
@@ -28,6 +23,8 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementTableComponent implements OnChanges {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
protected readonly columnConfig = [
@@ -51,10 +48,7 @@ export class DeviceManagementTableComponent implements OnChanges {
},
];
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
) {}
constructor(private i18nService: I18nService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.devices) {
@@ -62,24 +56,10 @@ export class DeviceManagementTableComponent implements OnChanges {
}
}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.tableDataSource.data = clearAuthRequestAndResortDevices(
this.devices,
pendingAuthRequest,
);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -30,11 +30,13 @@
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-item-group>
}

View File

@@ -16,14 +16,18 @@ import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { ButtonModule, PopoverModule } from "@bitwarden/components";
import { ButtonModule, DialogService, PopoverModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { LoginApprovalDialogComponent } from "../login-approval";
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
import { DeviceManagementTableComponent } from "./device-management-table.component";
import { clearAuthRequestAndResortDevices, resortDevices } from "./resort-devices.helper";
export interface DeviceDisplayData {
creationDate: string;
displayName: string;
firstLogin: Date;
icon: string;
@@ -66,6 +70,7 @@ export class DeviceManagementComponent implements OnInit {
private destroyRef: DestroyRef,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private messageListener: MessageListener,
private validationService: ValidationService,
@@ -130,6 +135,7 @@ export class DeviceManagementComponent implements OnInit {
}
return {
creationDate: device.creationDate,
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
icon: this.getDeviceIcon(device.type),
@@ -141,7 +147,8 @@ export class DeviceManagementComponent implements OnInit {
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
};
})
.filter((device) => device !== null);
.filter((device) => device !== null)
.sort(resortDevices);
}
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
@@ -151,6 +158,7 @@ export class DeviceManagementComponent implements OnInit {
}
const upsertDevice: DeviceDisplayData = {
creationDate: "",
displayName: this.devicesService.getReadableDeviceTypeName(
authRequestResponse.requestDeviceTypeValue,
),
@@ -174,8 +182,9 @@ export class DeviceManagementComponent implements OnInit {
);
if (existingDevice?.id && existingDevice.creationDate) {
upsertDevice.id = existingDevice.id;
upsertDevice.creationDate = existingDevice.creationDate;
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
upsertDevice.id = existingDevice.id;
}
}
@@ -186,10 +195,10 @@ export class DeviceManagementComponent implements OnInit {
if (existingDeviceIndex >= 0) {
// Update existing device in device list
this.devices[existingDeviceIndex] = upsertDevice;
this.devices = [...this.devices];
this.devices = [...this.devices].sort(resortDevices);
} else {
// Add new device to device list
this.devices = [upsertDevice, ...this.devices];
this.devices = [upsertDevice, ...this.devices].sort(resortDevices);
}
}
@@ -227,4 +236,18 @@ export class DeviceManagementComponent implements OnInit {
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
protected async handleAuthRequestAnswered(pendingAuthRequest: DevicePendingAuthRequest) {
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
}
}

View File

@@ -23,7 +23,7 @@ export function clearAuthRequestAndResortDevices(
*
* This is a helper function that gets passed to the `Array.sort()` method
*/
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
export function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
// Devices with a pending auth request should be first
if (deviceA.pendingAuthRequest) {
return -1;
@@ -40,11 +40,11 @@ function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
return 1;
}
// Then sort the rest by display name (alphabetically)
if (deviceA.displayName < deviceB.displayName) {
// Then sort the rest by creation date (newest to oldest)
if (deviceA.creationDate > deviceB.creationDate) {
return -1;
}
if (deviceA.displayName > deviceB.displayName) {
if (deviceA.creationDate < deviceB.creationDate) {
return 1;
}

View File

@@ -0,0 +1,48 @@
<ng-container
*ngIf="{
selectedRegion: selectedRegion$ | async,
} as data"
>
<div class="tw-mb-1">
<bit-menu #environmentOptions>
<button
*ngFor="let region of availableRegions; let i = index"
bitMenuItem
type="button"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
(click)="toggle(region.key)"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
></i>
<span>{{ region.domain }}</span>
</button>
<button
bitMenuItem
type="button"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
></i>
<span>{{ "selfHostedServer" | i18n }}</span>
</button>
</bit-menu>
<div bitTypography="body2">
{{ "accessing" | i18n }}:
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
<b class="tw-text-primary-600 tw-font-semibold">{{
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
}}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
</div>
</ng-container>

View File

@@ -0,0 +1,75 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { Observable, map, Subject } from "rxjs";
// 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import {
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DialogService,
LinkModule,
MenuModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
selector: "environment-selector",
templateUrl: "environment-selector.component.html",
standalone: true,
imports: [CommonModule, I18nPipe, MenuModule, LinkModule, TypographyModule],
})
export class EnvironmentSelectorComponent implements OnDestroy {
protected ServerEnvironmentType = Region;
protected availableRegions = this.environmentService.availableRegions();
protected selectedRegion$: Observable<RegionConfig | undefined> =
this.environmentService.environment$.pipe(
map((e) => e.getRegion()),
map((r) => this.availableRegions.find((ar) => ar.key === r)),
);
private destroy$ = new Subject<void>();
constructor(
public environmentService: EnvironmentService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async toggle(option: Region) {
if (option === null) {
return;
}
/**
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (option === Region.SelfHosted) {
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
if (dialogResult) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
}
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
return;
}
await this.environmentService.setEnvironment(option);
}
}

View File

@@ -0,0 +1,262 @@
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,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
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";
@@ -13,7 +22,7 @@ import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/model
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges {
@Input() startWith: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Input() showTaxIdField: boolean = true;
@@ -56,7 +65,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
}
submit = async () => {
this.formGroup.markAllAsTouched();
this.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
@@ -65,7 +74,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
};
validate(): boolean {
this.formGroup.markAllAsTouched();
this.markAllAsTouched();
return this.formGroup.valid;
}
@@ -142,6 +151,14 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
});
}
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

@@ -13,7 +13,6 @@ import {
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { HttpOperations } from "@bitwarden/common/services/api.service";
import { SafeInjectionToken } from "@bitwarden/ui-common";
@@ -33,7 +32,6 @@ export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken<
>("OBSERVABLE_DISK_LOCAL_STORAGE");
export const MEMORY_STORAGE = new SafeInjectionToken<AbstractStorageService>("MEMORY_STORAGE");
export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY");
export const LOGOUT_CALLBACK = new SafeInjectionToken<
(logoutReason: LogoutReason, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");

View File

@@ -151,6 +151,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/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 { DefaultKeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
@@ -186,7 +187,6 @@ import {
} from "@bitwarden/common/platform/abstractions/environment.service";
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -197,13 +197,10 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import { UnsupportedActionsService } from "@bitwarden/common/platform/actions/unsupported-actions.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import {
DefaultTaskSchedulerService,
TaskSchedulerService,
@@ -230,13 +227,13 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import {
ActiveUserAccessor,
ActiveUserStateProvider,
DefaultStateService,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
@@ -254,7 +251,7 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-
import { SyncService } from "@bitwarden/common/platform/sync";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications";
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service";
import {
DefaultThemeStateService,
@@ -375,12 +372,10 @@ import {
LOCKED_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
SECURE_STORAGE,
STATE_FACTORY,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
@@ -418,10 +413,6 @@ const safeProviders: SafeProvider[] = [
useFactory: (window: Window) => window.navigator.language,
deps: [WINDOW],
}),
safeProvider({
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
// TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService
safeProvider({
provide: LOGOUT_CALLBACK,
@@ -524,7 +515,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DomainSettingsService,
useClass: DefaultDomainSettingsService,
deps: [StateProvider, ConfigService],
deps: [StateProvider],
}),
safeProvider({
provide: CipherServiceAbstraction,
@@ -534,7 +525,6 @@ const safeProviders: SafeProvider[] = [
apiService: ApiServiceAbstraction,
i18nService: I18nServiceAbstraction,
searchService: SearchServiceAbstraction,
stateService: StateServiceAbstraction,
autofillSettingsService: AutofillSettingsServiceAbstraction,
encryptService: EncryptService,
fileUploadService: CipherFileUploadServiceAbstraction,
@@ -551,7 +541,6 @@ const safeProviders: SafeProvider[] = [
apiService,
i18nService,
searchService,
stateService,
autofillSettingsService,
encryptService,
fileUploadService,
@@ -568,7 +557,6 @@ const safeProviders: SafeProvider[] = [
ApiServiceAbstraction,
I18nServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
AutofillSettingsServiceAbstraction,
EncryptService,
CipherFileUploadServiceAbstraction,
@@ -666,15 +654,15 @@ const safeProviders: SafeProvider[] = [
GlobalStateProvider,
SUPPORTS_SECURE_STORAGE,
SECURE_STORAGE,
KeyGenerationServiceAbstraction,
KeyGenerationService,
EncryptService,
LogService,
LOGOUT_CALLBACK,
],
}),
safeProvider({
provide: KeyGenerationServiceAbstraction,
useClass: KeyGenerationService,
provide: KeyGenerationService,
useClass: DefaultKeyGenerationService,
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
@@ -683,7 +671,7 @@ const safeProviders: SafeProvider[] = [
deps: [
PinServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyGenerationServiceAbstraction,
KeyGenerationService,
CryptoFunctionServiceAbstraction,
EncryptService,
PlatformUtilsServiceAbstraction,
@@ -773,7 +761,7 @@ const safeProviders: SafeProvider[] = [
deps: [
KeyService,
I18nServiceAbstraction,
KeyGenerationServiceAbstraction,
KeyGenerationService,
SendStateProviderAbstraction,
EncryptService,
],
@@ -805,7 +793,6 @@ const safeProviders: SafeProvider[] = [
InternalSendService,
LogService,
KeyConnectorServiceAbstraction,
StateServiceAbstraction,
ProviderServiceAbstraction,
FolderApiServiceAbstraction,
InternalOrganizationServiceAbstraction,
@@ -853,6 +840,7 @@ const safeProviders: SafeProvider[] = [
MessagingServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
TokenServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsService,
StateEventRunnerService,
@@ -872,24 +860,10 @@ const safeProviders: SafeProvider[] = [
useClass: SsoLoginService,
deps: [StateProvider, LogService],
}),
safeProvider({
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
safeProvider({
provide: StateServiceAbstraction,
useClass: StateService,
deps: [
AbstractStorageService,
SECURE_STORAGE,
MEMORY_STORAGE,
LogService,
STATE_FACTORY,
AccountServiceAbstraction,
EnvironmentService,
TokenServiceAbstraction,
MigrationRunner,
],
useClass: DefaultStateService,
deps: [AbstractStorageService, SECURE_STORAGE, ActiveUserAccessor],
}),
safeProvider({
provide: IndividualVaultExportServiceAbstraction,
@@ -1049,7 +1023,7 @@ const safeProviders: SafeProvider[] = [
deps: [
StateProvider,
StateServiceAbstraction,
KeyGenerationServiceAbstraction,
KeyGenerationService,
EncryptService,
LogService,
CryptoFunctionServiceAbstraction,
@@ -1071,7 +1045,7 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction,
LogService,
OrganizationServiceAbstraction,
KeyGenerationServiceAbstraction,
KeyGenerationService,
LOGOUT_CALLBACK,
StateProvider,
],
@@ -1230,7 +1204,7 @@ const safeProviders: SafeProvider[] = [
provide: DeviceTrustServiceAbstraction,
useClass: DeviceTrustService,
deps: [
KeyGenerationServiceAbstraction,
KeyGenerationService,
CryptoFunctionServiceAbstraction,
KeyService,
EncryptService,
@@ -1266,7 +1240,7 @@ const safeProviders: SafeProvider[] = [
CryptoFunctionServiceAbstraction,
EncryptService,
KdfConfigService,
KeyGenerationServiceAbstraction,
KeyGenerationService,
LogService,
StateProvider,
],

View File

@@ -18,9 +18,8 @@
size="small"
*ngIf="!persistent"
(click)="handleDismiss()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
class="-tw-me-2"
[label]="'close' | i18n"
></button>
</div>

View File

@@ -8,7 +8,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
@@ -31,10 +30,6 @@ describe("Vault Nudges Service", () => {
let fakeStateProvider: FakeStateProvider;
let testBed: TestBed;
const mockConfigService = {
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const nudgeServices = [
EmptyVaultNudgeService,
@@ -58,7 +53,6 @@ describe("Vault Nudges Service", () => {
provide: StateProvider,
useValue: fakeStateProvider,
},
{ provide: ConfigService, useValue: mockConfigService },
{
provide: HasItemsNudgeService,
useValue: mock<HasItemsNudgeService>(),

View File

@@ -1,8 +1,6 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { combineLatest, map, Observable, shareReplay } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -83,7 +81,6 @@ export class NudgesService {
* @private
*/
private defaultNudgeService = inject(DefaultSingleNudgeService);
private configService = inject(ConfigService);
private getNudgeService(nudge: NudgeType): SingleNudgeService {
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
@@ -95,16 +92,9 @@ export class NudgesService {
* @param userId
*/
showNudgeSpotlight$(nudge: NudgeType, userId: UserId): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of(false);
}
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
}),
);
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
}
/**
@@ -113,16 +103,9 @@ export class NudgesService {
* @param userId
*/
showNudgeBadge$(nudge: NudgeType, userId: UserId): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of(false);
}
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
}),
);
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
}
/**
@@ -131,14 +114,7 @@ export class NudgesService {
* @param userId
*/
showNudgeStatus$(nudge: NudgeType, userId: UserId) {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
}
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
}),
);
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
}
/**