-
+
diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html
index 2ef177922a9..dd260848f52 100644
--- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html
+++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html
@@ -21,7 +21,7 @@
-
+
@@ -36,7 +36,7 @@
{{ credential.name }} |
-
+
{{ "usedForEncryption" | i18n }}
@@ -47,7 +47,7 @@
[attr.aria-label]="('enablePasskeyEncryption' | i18n) + ' ' + credential.name"
(click)="enableEncryption(credential.id)"
>
-
+
{{ "enablePasskeyEncryption" | i18n }}
diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.html b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.html
index 0770ea4dfe1..1f16fe817e1 100644
--- a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.html
+++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.html
@@ -21,7 +21,7 @@
{{ "sendCode" | i18n }}
-
+
{{ "codeSent" | i18n }}
diff --git a/apps/web/src/app/auth/verify-email-token.component.html b/apps/web/src/app/auth/verify-email-token.component.html
index 63437352e19..47e0d0f1517 100644
--- a/apps/web/src/app/auth/verify-email-token.component.html
+++ b/apps/web/src/app/auth/verify-email-token.component.html
@@ -1,6 +1,10 @@
-
+
diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts
index fe70f876bc4..f05f9c08b76 100644
--- a/apps/web/src/app/auth/verify-email-token.component.ts
+++ b/apps/web/src/app/auth/verify-email-token.component.ts
@@ -12,11 +12,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ToastService } from "@bitwarden/components";
+import { SharedModule } from "../shared/shared.module";
+
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",
+ imports: [SharedModule],
})
export class VerifyEmailTokenComponent implements OnInit {
constructor(
diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts
new file mode 100644
index 00000000000..aa4cbdab40e
--- /dev/null
+++ b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts
@@ -0,0 +1,2199 @@
+// These are disabled until we can migrate to signals and remove the use of @Input properties that are used within the mocked child components
+/* eslint-disable @angular-eslint/prefer-output-emitter-ref */
+/* eslint-disable @angular-eslint/prefer-signals */
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
+import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
+import { FormBuilder, Validators } from "@angular/forms";
+import { Router } from "@angular/router";
+import { BehaviorSubject, of } from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
+import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
+import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
+import { ToastService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+import {
+ PreviewInvoiceClient,
+ SubscriberBillingClient,
+} from "@bitwarden/web-vault/app/billing/clients";
+
+import { OrganizationInformationComponent } from "../../admin-console/organizations/create/organization-information.component";
+import { EnterBillingAddressComponent, EnterPaymentMethodComponent } from "../payment/components";
+import { SecretsManagerSubscribeComponent } from "../shared";
+import { OrganizationSelfHostingLicenseUploaderComponent } from "../shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
+
+import { OrganizationPlansComponent } from "./organization-plans.component";
+
+// Mocked Child Components
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: "app-org-info",
+ template: "",
+ standalone: true,
+})
+class MockOrgInfoComponent {
+ @Input() formGroup: any;
+ @Input() createOrganization = true;
+ @Input() isProvider = false;
+ @Input() acceptingSponsorship = false;
+ @Output() changedBusinessOwned = new EventEmitter();
+}
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: "sm-subscribe",
+ template: "",
+ standalone: true,
+})
+class MockSmSubscribeComponent {
+ @Input() formGroup: any;
+ @Input() selectedPlan: any;
+ @Input() upgradeOrganization = false;
+}
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: "app-enter-payment-method",
+ template: "",
+ standalone: true,
+})
+class MockEnterPaymentMethodComponent {
+ @Input() group: any;
+
+ static getFormGroup() {
+ const fb = new FormBuilder();
+ return fb.group({
+ type: fb.control("card"),
+ bankAccount: fb.group({
+ routingNumber: fb.control(""),
+ accountNumber: fb.control(""),
+ accountHolderName: fb.control(""),
+ accountHolderType: fb.control(""),
+ }),
+ billingAddress: fb.group({
+ country: fb.control("US"),
+ postalCode: fb.control(""),
+ }),
+ });
+ }
+
+ tokenize = jest.fn().mockResolvedValue({
+ token: "mock_token",
+ type: "card",
+ });
+}
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: "app-enter-billing-address",
+ template: "",
+ standalone: true,
+})
+class MockEnterBillingAddressComponent {
+ @Input() group: any;
+ @Input() scenario: any;
+
+ static getFormGroup() {
+ return new FormBuilder().group({
+ country: ["US", Validators.required],
+ postalCode: ["", Validators.required],
+ taxId: [""],
+ line1: [""],
+ line2: [""],
+ city: [""],
+ state: [""],
+ });
+ }
+}
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: "organization-self-hosting-license-uploader",
+ template: "",
+ standalone: true,
+})
+class MockOrganizationSelfHostingLicenseUploaderComponent {
+ @Output() onLicenseFileUploaded = new EventEmitter();
+}
+
+// Test Helper Functions
+
+/**
+ * Sets up mock encryption keys and org key services
+ */
+const setupMockEncryptionKeys = (
+ mockKeyService: jest.Mocked,
+ mockEncryptService: jest.Mocked,
+) => {
+ mockKeyService.makeOrgKey.mockResolvedValue([{ encryptedString: "mock-key" }, {} as any] as any);
+
+ mockEncryptService.encryptString.mockResolvedValue({
+ encryptedString: "mock-collection",
+ } as any);
+
+ mockKeyService.makeKeyPair.mockResolvedValue([
+ "public-key",
+ { encryptedString: "private-key" },
+ ] as any);
+};
+
+/**
+ * Sets up a mock payment method component that returns a successful tokenization
+ */
+const setupMockPaymentMethodComponent = (
+ component: OrganizationPlansComponent,
+ token = "mock_token",
+ type = "card",
+) => {
+ component["enterPaymentMethodComponent"] = {
+ tokenize: jest.fn().mockResolvedValue({ token, type }),
+ } as any;
+};
+
+/**
+ * Patches billing address form with standard test values
+ */
+const patchBillingAddress = (
+ component: OrganizationPlansComponent,
+ overrides: Partial<{
+ country: string;
+ postalCode: string;
+ line1: string;
+ line2: string;
+ city: string;
+ state: string;
+ taxId: string;
+ }> = {},
+) => {
+ component.billingFormGroup.controls.billingAddress.patchValue({
+ country: "US",
+ postalCode: "12345",
+ line1: "123 Street",
+ line2: "",
+ city: "City",
+ state: "CA",
+ taxId: "",
+ ...overrides,
+ });
+};
+
+/**
+ * Sets up a mock organization for upgrade scenarios
+ */
+const setupMockUpgradeOrganization = (
+ mockOrganizationApiService: jest.Mocked,
+ organizationsSubject: BehaviorSubject,
+ orgConfig: {
+ id?: string;
+ productTierType?: ProductTierType;
+ hasPaymentSource?: boolean;
+ planType?: PlanType;
+ seats?: number;
+ maxStorageGb?: number;
+ hasPublicAndPrivateKeys?: boolean;
+ useSecretsManager?: boolean;
+ smSeats?: number;
+ smServiceAccounts?: number;
+ } = {},
+) => {
+ const {
+ id = "org-123",
+ productTierType = ProductTierType.Free,
+ hasPaymentSource = true,
+ planType = PlanType.Free,
+ seats = 5,
+ maxStorageGb,
+ hasPublicAndPrivateKeys = true,
+ useSecretsManager = false,
+ smSeats,
+ smServiceAccounts,
+ } = orgConfig;
+
+ const mockOrganization = {
+ id,
+ name: "Test Org",
+ productTierType,
+ seats,
+ maxStorageGb,
+ hasPublicAndPrivateKeys,
+ useSecretsManager,
+ } as Organization;
+
+ organizationsSubject.next([mockOrganization]);
+
+ mockOrganizationApiService.getBilling.mockResolvedValue({
+ paymentSource: hasPaymentSource ? { type: "card" } : null,
+ } as any);
+
+ mockOrganizationApiService.getSubscription.mockResolvedValue({
+ planType,
+ smSeats,
+ smServiceAccounts,
+ } as any);
+
+ return mockOrganization;
+};
+
+/**
+ * Patches organization form with basic test values
+ */
+const patchOrganizationForm = (
+ component: OrganizationPlansComponent,
+ values: {
+ name?: string;
+ billingEmail?: string;
+ productTier?: ProductTierType;
+ plan?: PlanType;
+ additionalSeats?: number;
+ additionalStorage?: number;
+ },
+) => {
+ component.formGroup.patchValue({
+ name: "Test Org",
+ billingEmail: "test@example.com",
+ productTier: ProductTierType.Free,
+ plan: PlanType.Free,
+ additionalSeats: 0,
+ additionalStorage: 0,
+ ...values,
+ });
+};
+
+/**
+ * Returns plan details
+ *
+ */
+
+const createMockPlans = (): PlanResponse[] => {
+ return [
+ {
+ type: PlanType.Free,
+ productTier: ProductTierType.Free,
+ name: "Free",
+ isAnnual: true,
+ upgradeSortOrder: 1,
+ displaySortOrder: 1,
+ PasswordManager: {
+ basePrice: 0,
+ seatPrice: 0,
+ maxSeats: 2,
+ baseSeats: 2,
+ hasAdditionalSeatsOption: false,
+ hasAdditionalStorageOption: false,
+ hasPremiumAccessOption: false,
+ baseStorageGb: 0,
+ },
+ SecretsManager: null,
+ } as PlanResponse,
+ {
+ type: PlanType.FamiliesAnnually,
+ productTier: ProductTierType.Families,
+ name: "Families",
+ isAnnual: true,
+ upgradeSortOrder: 2,
+ displaySortOrder: 2,
+ PasswordManager: {
+ basePrice: 40,
+ seatPrice: 0,
+ maxSeats: 6,
+ baseSeats: 6,
+ hasAdditionalSeatsOption: false,
+ hasAdditionalStorageOption: true,
+ hasPremiumAccessOption: false,
+ baseStorageGb: 1,
+ additionalStoragePricePerGb: 4,
+ },
+ SecretsManager: null,
+ } as PlanResponse,
+ {
+ type: PlanType.TeamsAnnually,
+ productTier: ProductTierType.Teams,
+ name: "Teams",
+ isAnnual: true,
+ canBeUsedByBusiness: true,
+ upgradeSortOrder: 3,
+ displaySortOrder: 3,
+ PasswordManager: {
+ basePrice: 0,
+ seatPrice: 48,
+ hasAdditionalSeatsOption: true,
+ hasAdditionalStorageOption: true,
+ hasPremiumAccessOption: true,
+ baseStorageGb: 1,
+ additionalStoragePricePerGb: 4,
+ premiumAccessOptionPrice: 40,
+ },
+ SecretsManager: {
+ basePrice: 0,
+ seatPrice: 72,
+ hasAdditionalSeatsOption: true,
+ hasAdditionalServiceAccountOption: true,
+ baseServiceAccount: 50,
+ additionalPricePerServiceAccount: 6,
+ },
+ } as PlanResponse,
+ {
+ type: PlanType.EnterpriseAnnually,
+ productTier: ProductTierType.Enterprise,
+ name: "Enterprise",
+ isAnnual: true,
+ canBeUsedByBusiness: true,
+ trialPeriodDays: 7,
+ upgradeSortOrder: 4,
+ displaySortOrder: 4,
+ PasswordManager: {
+ basePrice: 0,
+ seatPrice: 72,
+ hasAdditionalSeatsOption: true,
+ hasAdditionalStorageOption: true,
+ hasPremiumAccessOption: true,
+ baseStorageGb: 1,
+ additionalStoragePricePerGb: 4,
+ premiumAccessOptionPrice: 40,
+ },
+ SecretsManager: {
+ basePrice: 0,
+ seatPrice: 144,
+ hasAdditionalSeatsOption: true,
+ hasAdditionalServiceAccountOption: true,
+ baseServiceAccount: 200,
+ additionalPricePerServiceAccount: 6,
+ },
+ } as PlanResponse,
+ ];
+};
+
+describe("OrganizationPlansComponent", () => {
+ let component: OrganizationPlansComponent;
+ let fixture: ComponentFixture;
+
+ // Mock services
+ let mockApiService: jest.Mocked;
+ let mockI18nService: jest.Mocked;
+ let mockPlatformUtilsService: jest.Mocked;
+ let mockKeyService: jest.Mocked;
+ let mockEncryptService: jest.Mocked;
+ let mockRouter: jest.Mocked;
+ let mockSyncService: jest.Mocked;
+ let mockPolicyService: jest.Mocked;
+ let mockOrganizationService: jest.Mocked;
+ let mockMessagingService: jest.Mocked |