From 02556c1416886828e9b7f7da5beb018dd44fae8c Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:09:37 +0100 Subject: [PATCH 1/4] Changes to restart cancelled org (#12730) --- .../change-plan-dialog.component.html | 22 +++- .../change-plan-dialog.component.ts | 118 +++++++++++++++++- .../organization-billing.module.ts | 2 - .../billing/services/trial-flow.service.ts | 83 ++++++++++-- .../components/vault-filter.component.ts | 38 +----- apps/web/src/locales/en/messages.json | 15 +++ .../billing-api.service.abstraction.ts | 6 + .../organization-billing.service.ts | 5 + .../organization-billing-metadata.response.ts | 2 + .../billing/services/billing-api.service.ts | 14 +++ .../services/organization-billing.service.ts | 13 ++ 11 files changed, 261 insertions(+), 57 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 78005275f12..902cac9c771 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -1,16 +1,19 @@
- {{ "upgradeFreeOrganization" | i18n: currentPlanName }} + {{ dialogHeaderName }}

{{ "upgradePlans" | i18n }}

- {{ "selectAPlan" | i18n }} + {{ + "selectAPlan" | i18n + }}
+

{{ "paymentMethod" | i18n }}

-

+

{{ deprecateStripeSourcesAPI diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 9a80de555c6..d7ac442c40c 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -24,7 +24,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { + BillingApiServiceAbstraction, + BillingInformation, + OrganizationInformation, + PaymentInformation, + PlanInformation, + OrganizationBillingServiceAbstraction as OrganizationBillingService, +} from "@bitwarden/common/billing/abstractions"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { PaymentMethodType, @@ -49,6 +56,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { BillingSharedModule } from "../shared/billing-shared.module"; import { PaymentV2Component } from "../shared/payment/payment-v2.component"; import { PaymentComponent } from "../shared/payment/payment.component"; @@ -89,6 +97,8 @@ interface OnSuccessArgs { @Component({ templateUrl: "./change-plan-dialog.component.html", + standalone: true, + imports: [BillingSharedModule], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @@ -163,6 +173,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { organization: Organization; sub: OrganizationSubscriptionResponse; billing: BillingResponse; + dialogHeaderName: string; currentPlanName: string; showPayment: boolean = false; totalOpened: boolean = false; @@ -174,6 +185,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { paymentSource?: PaymentSourceResponse; deprecateStripeSourcesAPI: boolean; + isSubscriptionCanceled: boolean = false; private destroy$ = new Subject(); @@ -196,6 +208,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private configService: ConfigService, private billingApiService: BillingApiServiceAbstraction, private taxService: TaxServiceAbstraction, + private organizationBillingService: OrganizationBillingService, ) {} async ngOnInit(): Promise { @@ -208,6 +221,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.sub = this.dialogParams.subscription ?? (await this.organizationApiService.getSubscription(this.dialogParams.organizationId)); + this.dialogHeaderName = this.resolveHeaderName(this.sub); this.organizationId = this.dialogParams.organizationId; this.currentPlan = this.sub?.plan; this.selectedPlan = this.sub?.plan; @@ -281,6 +295,20 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.refreshSalesTax(); } + resolveHeaderName(subscription: OrganizationSubscriptionResponse): string { + if (subscription.subscription != null) { + this.isSubscriptionCanceled = subscription.subscription.cancelled; + if (subscription.subscription.cancelled) { + return this.i18nService.t("restartSubscription"); + } + } + + return this.i18nService.t( + "upgradeFreeOrganization", + this.resolvePlanName(this.dialogParams.productTierType), + ); + } + setInitialPlanSelection() { this.focusedIndex = this.selectableProducts.length - 1; this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); @@ -388,6 +416,19 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ]; } case PlanCardState.Disabled: { + if (this.isSubscriptionCanceled) { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + "tw-w-80", + ]; + } + return [ "tw-cursor-not-allowed", "tw-bg-secondary-100", @@ -409,7 +450,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return; } - if (plan === this.currentPlan) { + if (plan === this.currentPlan && !this.isSubscriptionCanceled) { return; } this.selectedPlan = plan; @@ -446,6 +487,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get selectableProducts() { + if (this.isSubscriptionCanceled) { + // Return only the current plan if the subscription is canceled + return [this.currentPlan]; + } + if (this.acceptingSponsorship) { const familyPlan = this.passwordManagerPlans.find( (plan) => plan.type === PlanType.FamiliesAnnually, @@ -692,11 +738,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { const doSubmit = async (): Promise => { let orgId: string = null; - orgId = await this.updateOrganization(); + if (this.isSubscriptionCanceled) { + await this.restartSubscription(); + orgId = this.organizationId; + } else { + orgId = await this.updateOrganization(); + } this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("organizationUpgraded"), + message: this.isSubscriptionCanceled + ? this.i18nService.t("restartOrganizationSubscription") + : this.i18nService.t("organizationUpgraded"), }); await this.apiService.refreshIdentityToken(); @@ -726,6 +779,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; + private async restartSubscription() { + const org = await this.organizationApiService.get(this.organizationId); + const organization: OrganizationInformation = { + name: org.name, + billingEmail: org.billingEmail, + }; + + const plan: PlanInformation = { + type: this.selectedPlan.type, + passwordManagerSeats: org.seats, + }; + + if (org.useSecretsManager) { + plan.subscribeToSecretsManager = true; + plan.secretsManagerSeats = org.smSeats; + } + + let paymentMethod: [string, PaymentMethodType]; + + if (this.deprecateStripeSourcesAPI) { + const { type, token } = await this.paymentV2Component.tokenize(); + paymentMethod = [token, type]; + } else { + paymentMethod = await this.paymentComponent.createPaymentToken(); + } + + const payment: PaymentInformation = { + paymentMethod, + billing: this.getBillingInformationFromTaxInfoComponent(), + }; + + await this.organizationBillingService.restartSubscription(this.organization.id, { + organization, + plan, + payment, + }); + } + private async updateOrganization() { const request = new OrganizationUpgradeRequest(); if (this.selectedPlan.productTier !== ProductTierType.Families) { @@ -802,6 +893,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return text; } + private getBillingInformationFromTaxInfoComponent(): BillingInformation { + return { + country: this.taxInformation.country, + postalCode: this.taxInformation.postalCode, + taxId: this.taxInformation.taxId, + addressLine1: this.taxInformation.line1, + addressLine2: this.taxInformation.line2, + city: this.taxInformation.city, + state: this.taxInformation.state, + }; + } + private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void { request.useSecretsManager = this.organization.useSecretsManager; if (!this.organization.useSecretsManager) { @@ -997,6 +1100,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } protected canUpdatePaymentInformation(): boolean { - return this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty(); + return ( + this.upgradeRequiresPaymentMethod || + this.showPayment || + this.isPaymentSourceEmpty() || + this.isSubscriptionCanceled + ); } } diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index b25cda662f2..48ac613711d 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -8,7 +8,6 @@ import { BillingSharedModule } from "../shared"; import { AdjustSubscription } from "./adjust-subscription.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { BillingSyncKeyComponent } from "./billing-sync-key.component"; -import { ChangePlanDialogComponent } from "./change-plan-dialog.component"; import { ChangePlanComponent } from "./change-plan.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; @@ -44,7 +43,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, - ChangePlanDialogComponent, OrganizationPaymentMethodComponent, ], }) diff --git a/apps/web/src/app/billing/services/trial-flow.service.ts b/apps/web/src/app/billing/services/trial-flow.service.ts index 558851ad64c..a3a4ba6bba1 100644 --- a/apps/web/src/app/billing/services/trial-flow.service.ts +++ b/apps/web/src/app/billing/services/trial-flow.service.ts @@ -2,25 +2,37 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; +import { lastValueFrom } from "rxjs"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService } from "@bitwarden/components"; import { FreeTrial } from "../../core/types/free-trial"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../organizations/change-plan-dialog.component"; @Injectable({ providedIn: "root" }) export class TrialFlowService { + private resellerManagedOrgAlert: boolean; + constructor( private i18nService: I18nService, protected dialogService: DialogService, private router: Router, protected billingApiService: BillingApiServiceAbstraction, + private organizationApiService: OrganizationApiServiceAbstraction, + private configService: ConfigService, ) {} checkForOrgsWithUpcomingPaymentIssues( organization: Organization, @@ -66,16 +78,31 @@ export class TrialFlowService { org: Organization, organizationBillingMetadata: OrganizationBillingMetadataResponse, ): Promise { - if (organizationBillingMetadata.isSubscriptionUnpaid) { - const confirmed = await this.promptForPaymentNavigation(org); + if ( + organizationBillingMetadata.isSubscriptionUnpaid || + organizationBillingMetadata.isSubscriptionCanceled + ) { + const confirmed = await this.promptForPaymentNavigation( + org, + organizationBillingMetadata.isSubscriptionCanceled, + organizationBillingMetadata.isSubscriptionUnpaid, + ); if (confirmed) { await this.navigateToPaymentMethod(org?.id); } } } - private async promptForPaymentNavigation(org: Organization): Promise { - if (!org?.isOwner) { + private async promptForPaymentNavigation( + org: Organization, + isCanceled: boolean, + isUnpaid: boolean, + ): Promise { + this.resellerManagedOrgAlert = await this.configService.getFeatureFlag( + FeatureFlag.ResellerManagedOrgAlert, + ); + + if (!org?.isOwner && !org.providerId) { await this.dialogService.openSimpleDialog({ title: this.i18nService.t("suspendedOrganizationTitle", org?.name), content: { key: "suspendedUserOrgMessage" }, @@ -85,13 +112,31 @@ export class TrialFlowService { }); return false; } - return await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("continue"), - cancelButtonText: this.i18nService.t("close"), - }); + + if (org.providerId && this.resellerManagedOrgAlert) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org.name), + content: { key: "suspendedManagedOrgMessage", placeholders: [org.providerName] }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + + if (org.isOwner && isUnpaid) { + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + if (org.isOwner && isCanceled && this.resellerManagedOrgAlert) { + await this.changePlan(org); + } } private async navigateToPaymentMethod(orgId: string) { @@ -99,4 +144,20 @@ export class TrialFlowService { state: { launchPaymentModalAutomatically: true }, }); } + + private async changePlan(org: Organization) { + const subscription = await this.organizationApiService.getSubscription(org.id); + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: org.id, + subscription: subscription, + productTierType: org.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + if (result === ChangePlanDialogResultType.Closed) { + return; + } + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index efb1754c811..f568ba159a6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -6,7 +6,6 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -16,6 +15,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { VaultFilterList, @@ -91,6 +91,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return "searchVault"; } + private trialFlowService = inject(TrialFlowService); + constructor( protected vaultFilterService: VaultFilterService, protected policyService: PolicyService, @@ -126,13 +128,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.i18nService.t("disabledOrganizationFilterError"), ); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); - if (metadata.isSubscriptionUnpaid) { - const confirmed = await this.promptForPaymentNavigation(orgNode.node); - if (confirmed) { - await this.navigateToPaymentMethod(orgNode.node.id); - } - } - return; + await this.trialFlowService.handleUnpaidSubscriptionDialog(orgNode.node, metadata); } const filter = this.activeFilter; if (orgNode?.node.id === "AllVaults") { @@ -144,32 +140,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { await this.vaultFilterService.expandOrgFilter(); }; - private async promptForPaymentNavigation(org: Organization): Promise { - if (!org?.isOwner) { - await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedUserOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("close"), - cancelButtonText: null, - }); - return false; - } - return await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("suspendedOrganizationTitle", org?.name), - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: this.i18nService.t("continue"), - cancelButtonText: this.i18nService.t("close"), - }); - } - - private async navigateToPaymentMethod(orgId: string) { - await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { - state: { launchPaymentModalAutomatically: true }, - }); - } - applyTypeFilter = async (filterNode: TreeNode): Promise => { const filter = this.activeFilter; filter.resetFilter(); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 08e08ccad15..b0cbd050c13 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10066,5 +10066,20 @@ "example": "02/14/2024" } } + }, + "restartOrganizationSubscription": { + "message": "Organization subscription restarted" + }, + "restartSubscription": { + "message": "Restart your subscription" + }, + "suspendedManagedOrgMessage": { + "message": "Contact $PROVIDER$ for assistance.", + "placeholders": { + "provider": { + "content": "$1", + "example": "Acme c" + } + } } } diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index 8b82795fb50..4b08b52a136 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -8,6 +8,7 @@ import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/reque import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response"; import { PaymentMethodResponse } from "@bitwarden/common/billing/models/response/payment-method.response"; +import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; 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"; @@ -74,4 +75,9 @@ export abstract class BillingApiServiceAbstraction { organizationId: string, request: VerifyBankAccountRequest, ) => Promise; + + restartSubscription: ( + organizationId: string, + request: OrganizationCreateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index ddcd61573a6..7c4e0a39f8f 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -57,4 +57,9 @@ export abstract class OrganizationBillingServiceAbstraction { ) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + restartSubscription: ( + organizationId: string, + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index c5023cb64c1..d30ad76a147 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -10,6 +10,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { invoiceDueDate: Date | null; invoiceCreatedDate: Date | null; subPeriodEndDate: Date | null; + isSubscriptionCanceled: boolean; constructor(response: any) { super(response); @@ -23,6 +24,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate")); this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); + this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); } private parseDate(dateString: any): Date | null { diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index cb69f294409..7ce5602f3cc 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -10,6 +10,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ToastService } from "@bitwarden/components"; import { ApiService } from "../../abstractions/api.service"; +import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { BillingApiServiceAbstraction } from "../../billing/abstractions"; import { PaymentMethodType } from "../../billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "../../billing/models/request/expanded-tax-info-update.request"; @@ -214,6 +215,19 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } + async restartSubscription( + organizationId: string, + request: OrganizationCreateRequest, + ): Promise { + return await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/billing/restart-subscription", + request, + true, + false, + ); + } + private async execute(request: () => Promise): Promise { try { return await request(); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index a0b3782f1ad..ca10b368662 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -223,4 +223,17 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.additionalStorageGb = information.storage; } } + + async restartSubscription( + organizationId: string, + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationCreateRequest(); + const organizationKeys = await this.makeOrganizationKeys(); + this.setOrganizationKeys(request, organizationKeys); + this.setOrganizationInformation(request, subscription.organization); + this.setPlanInformation(request, subscription.plan); + this.setPaymentInformation(request, subscription.payment); + await this.billingApiService.restartSubscription(organizationId, request); + } } From f99a3c41627db44ffe573e091b3a35fbf091359a Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:29:36 -0600 Subject: [PATCH 2/4] feat(web): [PM-1214] add device management screen Adds a device management tab under settings -> security that allows users to: - View and manage their account's connected devices - Remove/deactivate devices - See device details like platform, last login, and trust status - Sort and filter device list with virtual scrolling Resolves PM-1214 --- .../browser/src/background/main.background.ts | 5 +- .../security/device-management.component.html | 88 +++++++ .../security/device-management.component.ts | 220 ++++++++++++++++++ .../security/security-routing.module.ts | 6 + .../settings/security/security.component.html | 1 + .../settings/security/security.component.ts | 6 +- apps/web/src/locales/en/messages.json | 39 ++++ .../src/services/jslib-services.module.ts | 2 +- .../devices-api.service.abstraction.ts | 6 + .../devices/devices.service.abstraction.ts | 15 +- .../devices/responses/device.response.ts | 5 + .../abstractions/devices/views/device.view.ts | 1 + .../request/update-devices-trust.request.ts | 4 +- ...devices-api.service.implementation.spec.ts | 100 ++++++++ .../devices-api.service.implementation.ts | 4 + .../devices/devices.service.implementation.ts | 24 +- libs/common/src/enums/device-type.enum.ts | 50 ++-- 17 files changed, 549 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/auth/settings/security/device-management.component.html create mode 100644 apps/web/src/app/auth/settings/security/device-management.component.ts create mode 100644 libs/common/src/auth/services/devices-api.service.implementation.spec.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ff240ec8cac..bcfa797e0ff 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -769,7 +769,10 @@ export default class MainBackground { this.configService, ); - this.devicesService = new DevicesServiceImplementation(this.devicesApiService); + this.devicesService = new DevicesServiceImplementation( + this.devicesApiService, + this.appIdService, + ); this.authRequestService = new AuthRequestService( this.appIdService, diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management.component.html new file mode 100644 index 00000000000..6bae88fac51 --- /dev/null +++ b/apps/web/src/app/auth/settings/security/device-management.component.html @@ -0,0 +1,88 @@ + +

+
+

{{ "devices" | i18n }}

+ + +

{{ "aDeviceIs" | i18n }}

+
+ +
+
+ +

{{ "deviceListDescription" | i18n }}

+ +
+ +
+ + + + + {{ col.title }} + + + + + +
+ +
+
+ {{ row.displayName }} + + {{ "trusted" | i18n }} + +
+ + + {{ + "currentSession" | i18n + }} + {{ + "requestPending" | i18n + }} + + {{ row.firstLogin | date: "medium" }} + + + + + + +
+
+ diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management.component.ts new file mode 100644 index 00000000000..65f2afc250e --- /dev/null +++ b/apps/web/src/app/auth/settings/security/device-management.component.ts @@ -0,0 +1,220 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { firstValueFrom } from "rxjs"; +import { switchMap } from "rxjs/operators"; + +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; +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 { + DialogService, + ToastService, + TableDataSource, + TableModule, + PopoverModule, +} from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +interface DeviceTableData { + id: string; + type: DeviceType; + displayName: string; + loginStatus: string; + firstLogin: Date; + trusted: boolean; + devicePendingAuthRequest: object | null; +} + +/** + * Provides a table of devices and allows the user to log out, approve or remove a device + */ +@Component({ + selector: "app-device-management", + templateUrl: "./device-management.component.html", + standalone: true, + imports: [CommonModule, SharedModule, TableModule, PopoverModule], +}) +export class DeviceManagementComponent { + protected readonly tableId = "device-management-table"; + protected dataSource = new TableDataSource(); + protected currentDevice: DeviceView | undefined; + protected loading = true; + protected asyncActionLoading = false; + + constructor( + private i18nService: I18nService, + private devicesService: DevicesServiceAbstraction, + private dialogService: DialogService, + private toastService: ToastService, + private validationService: ValidationService, + ) { + this.devicesService + .getCurrentDevice$() + .pipe( + takeUntilDestroyed(), + switchMap((currentDevice) => { + this.currentDevice = new DeviceView(currentDevice); + return this.devicesService.getDevices$(); + }), + ) + .subscribe({ + next: (devices) => { + this.dataSource.data = devices.map((device) => { + return { + id: device.id, + type: device.type, + displayName: this.getHumanReadableDeviceType(device.type), + loginStatus: this.getLoginStatus(device), + devicePendingAuthRequest: device.response.devicePendingAuthRequest, + firstLogin: new Date(device.creationDate), + trusted: device.response.isTrusted, + }; + }); + this.loading = false; + }, + error: () => { + this.loading = false; + }, + }); + } + + /** + * Column configuration for the table + */ + protected readonly columnConfig = [ + { + name: "displayName", + title: this.i18nService.t("device"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "loginStatus", + title: this.i18nService.t("loginStatus"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "firstLogin", + title: this.i18nService.t("firstLogin"), + headerClass: "tw-w-1/3", + sortable: true, + }, + ]; + + /** + * Get the icon for a device type + * @param type - The device type + * @returns The icon for the device type + */ + getDeviceIcon(type: DeviceType): string { + const defaultIcon = "bwi bwi-desktop"; + const categoryIconMap: Record = { + webVault: "bwi bwi-browser", + desktop: "bwi bwi-desktop", + mobile: "bwi bwi-mobile", + cli: "bwi bwi-cli", + extension: "bwi bwi-puzzle", + sdk: "bwi bwi-desktop", + }; + + const metadata = DeviceTypeMetadata[type]; + return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon; + } + + /** + * Get the login status of a device + * It will return the current session if the device is the current device + * It will return the date of the pending auth request when available + * @param device - The device + * @returns The login status + */ + private getLoginStatus(device: DeviceView): string { + if (this.isCurrentDevice(device)) { + return this.i18nService.t("currentSession"); + } + + if (device.response.devicePendingAuthRequest?.creationDate) { + return this.i18nService.t("requestPending"); + } + + return ""; + } + + /** + * Get a human readable device type from the DeviceType enum + * @param type - The device type + * @returns The human readable device type + */ + private getHumanReadableDeviceType(type: DeviceType): string { + const metadata = DeviceTypeMetadata[type]; + if (!metadata) { + return this.i18nService.t("unknownDevice"); + } + + // If the platform is "Unknown" translate it since it is not a proper noun + const platform = + metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform; + const category = this.i18nService.t(metadata.category); + return platform ? `${category} - ${platform}` : category; + } + + /** + * Check if a device is the current device + * @param device - The device or device table data + * @returns True if the device is the current device, false otherwise + */ + protected isCurrentDevice(device: DeviceView | DeviceTableData): boolean { + return "response" in device + ? device.id === this.currentDevice?.id + : device.id === this.currentDevice?.id; + } + + /** + * Check if a device has a pending auth request + * @param device - The device + * @returns True if the device has a pending auth request, false otherwise + */ + protected hasPendingAuthRequest(device: DeviceTableData): boolean { + return ( + device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null + ); + } + + /** + * Remove a device + * @param device - The device + */ + protected async removeDevice(device: DeviceTableData) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removeDevice" }, + content: { key: "removeDeviceConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + this.asyncActionLoading = true; + await firstValueFrom(this.devicesService.deactivateDevice$(device.id)); + this.asyncActionLoading = false; + + // Remove the device from the data source + this.dataSource.data = this.dataSource.data.filter((d) => d.id !== device.id); + + this.toastService.showToast({ + title: "", + message: this.i18nService.t("deviceRemoved"), + variant: "success", + }); + } catch (error) { + this.validationService.showError(error); + } + } +} diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index 8af0499d05a..6ed21605184 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -4,6 +4,7 @@ import { RouterModule, Routes } from "@angular/router"; import { ChangePasswordComponent } from "../change-password.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; +import { DeviceManagementComponent } from "./device-management.component"; import { SecurityKeysComponent } from "./security-keys.component"; import { SecurityComponent } from "./security.component"; @@ -29,6 +30,11 @@ const routes: Routes = [ component: SecurityKeysComponent, data: { titleId: "keys" }, }, + { + path: "device-management", + component: DeviceManagementComponent, + data: { titleId: "devices" }, + }, ], }, ]; diff --git a/apps/web/src/app/auth/settings/security/security.component.html b/apps/web/src/app/auth/settings/security/security.component.html index 25459faeacc..6bd7c1daf36 100644 --- a/apps/web/src/app/auth/settings/security/security.component.html +++ b/apps/web/src/app/auth/settings/security/security.component.html @@ -4,6 +4,7 @@ {{ "masterPassword" | i18n }}
{{ "twoStepLogin" | i18n }} + {{ "devices" | i18n }} {{ "keys" | i18n }} diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index 1df8145a917..d643b565df2 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from "@angular/core"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @Component({ selector: "app-security", @@ -9,7 +10,10 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use export class SecurityComponent implements OnInit { showChangePassword = true; - constructor(private userVerificationService: UserVerificationService) {} + constructor( + private userVerificationService: UserVerificationService, + private configService: ConfigService, + ) {} async ngOnInit() { this.showChangePassword = await this.userVerificationService.hasMasterPassword(); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b0cbd050c13..45aa1c34234 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1128,6 +1128,12 @@ "verifyIdentity": { "message": "Verify your Identity" }, + "whatIsADevice": { + "message": "What is a device?" + }, + "aDeviceIs": { + "message": "A device is a unique installation of the Bitwarden app where you have logged in. Reinstalling, clearing app data, or clearing your cookies could result in a device appearing multiple times." + }, "logInInitiated": { "message": "Log in initiated" }, @@ -1715,6 +1721,12 @@ "logBackIn": { "message": "Please log back in." }, + "currentSession": { + "message": "Current session" + }, + "requestPending": { + "message": "Request pending" + }, "logBackInOthersToo": { "message": "Please log back in. If you are using other Bitwarden applications log out and back in to those as well." }, @@ -3765,6 +3777,15 @@ "device": { "message": "Device" }, + "loginStatus": { + "message": "Login status" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, "creatingAccountOn": { "message": "Creating account on" }, @@ -8236,6 +8257,18 @@ "approveRequest": { "message": "Approve request" }, + "deviceApproved": { + "message": "Device approved" + }, + "deviceRemoved": { + "message": "Device removed" + }, + "removeDevice": { + "message": "Remove device" + }, + "removeDeviceConfirmation": { + "message": "Are you sure you want to remove this device?" + }, "noDeviceRequests": { "message": "No device requests" }, @@ -9939,6 +9972,12 @@ "removeMembers": { "message": "Remove members" }, + "devices": { + "message": "Devices" + }, + "deviceListDescription": { + "message": "Your account was logged in to each of the devices below. If you do not recognize a device, remove it now." + }, "claimedDomains": { "message": "Claimed domains" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index f9a72f24476..d990a7315f2 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1109,7 +1109,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DevicesServiceAbstraction, useClass: DevicesServiceImplementation, - deps: [DevicesApiServiceAbstraction], + deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction], }), safeProvider({ provide: DeviceTrustServiceAbstraction, diff --git a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts index 0af89928449..92f0ebf1667 100644 --- a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts @@ -36,4 +36,10 @@ export abstract class DevicesApiServiceAbstraction { * @param deviceIdentifier - current device identifier */ postDeviceTrustLoss: (deviceIdentifier: string) => Promise; + + /** + * Deactivates a device + * @param deviceId - The device ID + */ + deactivateDevice: (deviceId: string) => Promise; } diff --git a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts index a02ccc64876..ba6890947c1 100644 --- a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts @@ -1,17 +1,18 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; +import { DeviceResponse } from "./responses/device.response"; import { DeviceView } from "./views/device.view"; export abstract class DevicesServiceAbstraction { - getDevices$: () => Observable>; - getDeviceByIdentifier$: (deviceIdentifier: string) => Observable; - isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable; - updateTrustedDeviceKeys$: ( + abstract getDevices$(): Observable>; + abstract getDeviceByIdentifier$(deviceIdentifier: string): Observable; + abstract isDeviceKnownForUser$(email: string, deviceIdentifier: string): Observable; + abstract updateTrustedDeviceKeys$( deviceIdentifier: string, devicePublicKeyEncryptedUserKey: string, userKeyEncryptedDevicePublicKey: string, deviceKeyEncryptedDevicePrivateKey: string, - ) => Observable; + ): Observable; + abstract deactivateDevice$(deviceId: string): Observable; + abstract getCurrentDevice$(): Observable; } diff --git a/libs/common/src/auth/abstractions/devices/responses/device.response.ts b/libs/common/src/auth/abstractions/devices/responses/device.response.ts index a4e40037b05..707616744ad 100644 --- a/libs/common/src/auth/abstractions/devices/responses/device.response.ts +++ b/libs/common/src/auth/abstractions/devices/responses/device.response.ts @@ -9,6 +9,9 @@ export class DeviceResponse extends BaseResponse { type: DeviceType; creationDate: string; revisionDate: string; + isTrusted: boolean; + devicePendingAuthRequest: { id: string; creationDate: string } | null; + constructor(response: any) { super(response); this.id = this.getResponseProperty("Id"); @@ -18,5 +21,7 @@ export class DeviceResponse extends BaseResponse { this.type = this.getResponseProperty("Type"); this.creationDate = this.getResponseProperty("CreationDate"); this.revisionDate = this.getResponseProperty("RevisionDate"); + this.isTrusted = this.getResponseProperty("IsTrusted"); + this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest"); } } diff --git a/libs/common/src/auth/abstractions/devices/views/device.view.ts b/libs/common/src/auth/abstractions/devices/views/device.view.ts index a901eb998b4..22e522b9eb0 100644 --- a/libs/common/src/auth/abstractions/devices/views/device.view.ts +++ b/libs/common/src/auth/abstractions/devices/views/device.view.ts @@ -12,6 +12,7 @@ export class DeviceView implements View { type: DeviceType; creationDate: string; revisionDate: string; + response: DeviceResponse; constructor(deviceResponse: DeviceResponse) { Object.assign(this, deviceResponse); diff --git a/libs/common/src/auth/models/request/update-devices-trust.request.ts b/libs/common/src/auth/models/request/update-devices-trust.request.ts index 8e3ce86c1a9..21fe0f600dc 100644 --- a/libs/common/src/auth/models/request/update-devices-trust.request.ts +++ b/libs/common/src/auth/models/request/update-devices-trust.request.ts @@ -8,8 +8,8 @@ export class UpdateDevicesTrustRequest extends SecretVerificationRequest { } export class DeviceKeysUpdateRequest { - encryptedPublicKey: string; - encryptedUserKey: string; + encryptedPublicKey: string | undefined; + encryptedUserKey: string | undefined; } export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest { diff --git a/libs/common/src/auth/services/devices-api.service.implementation.spec.ts b/libs/common/src/auth/services/devices-api.service.implementation.spec.ts new file mode 100644 index 00000000000..7aea36c7bd4 --- /dev/null +++ b/libs/common/src/auth/services/devices-api.service.implementation.spec.ts @@ -0,0 +1,100 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "../../abstractions/api.service"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; + +import { DevicesApiServiceImplementation } from "./devices-api.service.implementation"; + +describe("DevicesApiServiceImplementation", () => { + let devicesApiService: DevicesApiServiceImplementation; + let apiService: MockProxy; + + beforeEach(() => { + apiService = mock(); + devicesApiService = new DevicesApiServiceImplementation(apiService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("getKnownDevice", () => { + it("calls api with correct parameters", async () => { + const email = "test@example.com"; + const deviceIdentifier = "device123"; + apiService.send.mockResolvedValue(true); + + const result = await devicesApiService.getKnownDevice(email, deviceIdentifier); + + expect(result).toBe(true); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/devices/knowndevice", + null, + false, + true, + null, + expect.any(Function), + ); + }); + }); + + describe("getDeviceByIdentifier", () => { + it("returns device response", async () => { + const deviceIdentifier = "device123"; + const mockResponse = { id: "123", name: "Test Device" }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await devicesApiService.getDeviceByIdentifier(deviceIdentifier); + + expect(result).toBeInstanceOf(DeviceResponse); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + `/devices/identifier/${deviceIdentifier}`, + null, + true, + true, + ); + }); + }); + + describe("updateTrustedDeviceKeys", () => { + it("updates device keys and returns device response", async () => { + const deviceIdentifier = "device123"; + const publicKeyEncrypted = "encryptedPublicKey"; + const userKeyEncrypted = "encryptedUserKey"; + const deviceKeyEncrypted = "encryptedDeviceKey"; + const mockResponse = { id: "123", name: "Test Device" }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await devicesApiService.updateTrustedDeviceKeys( + deviceIdentifier, + publicKeyEncrypted, + userKeyEncrypted, + deviceKeyEncrypted, + ); + + expect(result).toBeInstanceOf(DeviceResponse); + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + `/devices/${deviceIdentifier}/keys`, + { + encryptedPrivateKey: deviceKeyEncrypted, + encryptedPublicKey: userKeyEncrypted, + encryptedUserKey: publicKeyEncrypted, + }, + true, + true, + ); + }); + }); + + describe("error handling", () => { + it("propagates api errors", async () => { + const error = new Error("API Error"); + apiService.send.mockRejectedValue(error); + + await expect(devicesApiService.getDevices()).rejects.toThrow("API Error"); + }); + }); +}); diff --git a/libs/common/src/auth/services/devices-api.service.implementation.ts b/libs/common/src/auth/services/devices-api.service.implementation.ts index 711f0bb68ec..cf760effbdf 100644 --- a/libs/common/src/auth/services/devices-api.service.implementation.ts +++ b/libs/common/src/auth/services/devices-api.service.implementation.ts @@ -117,4 +117,8 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac }, ); } + + async deactivateDevice(deviceId: string): Promise { + await this.apiService.send("POST", `/devices/${deviceId}/deactivate`, null, true, false); + } } diff --git a/libs/common/src/auth/services/devices/devices.service.implementation.ts b/libs/common/src/auth/services/devices/devices.service.implementation.ts index 6032ed66a89..cd6f1148dd8 100644 --- a/libs/common/src/auth/services/devices/devices.service.implementation.ts +++ b/libs/common/src/auth/services/devices/devices.service.implementation.ts @@ -1,5 +1,7 @@ import { Observable, defer, map } from "rxjs"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; + import { ListResponse } from "../../../models/response/list.response"; import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction"; import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; @@ -15,7 +17,10 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser * (i.e., promsise --> observables are cold until subscribed to) */ export class DevicesServiceImplementation implements DevicesServiceAbstraction { - constructor(private devicesApiService: DevicesApiServiceAbstraction) {} + constructor( + private devicesApiService: DevicesApiServiceAbstraction, + private appIdService: AppIdService, + ) {} /** * @description Gets the list of all devices. @@ -65,4 +70,21 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction { ), ).pipe(map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse))); } + + /** + * @description Deactivates a device + */ + deactivateDevice$(deviceId: string): Observable { + return defer(() => this.devicesApiService.deactivateDevice(deviceId)); + } + + /** + * @description Gets the current device. + */ + getCurrentDevice$(): Observable { + return defer(async () => { + const deviceIdentifier = await this.appIdService.getAppId(); + return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier); + }); + } } diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index 1b8574a4c49..ff6329b9ac4 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -27,18 +27,40 @@ export enum DeviceType { LinuxCLI = 25, } -export const MobileDeviceTypes: Set = new Set([ - DeviceType.Android, - DeviceType.iOS, - DeviceType.AndroidAmazon, -]); +/** + * Device type metadata + * Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.) + */ +interface DeviceTypeMetadata { + category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server"; + platform: string; +} -export const DesktopDeviceTypes: Set = new Set([ - DeviceType.WindowsDesktop, - DeviceType.MacOsDesktop, - DeviceType.LinuxDesktop, - DeviceType.UWP, - DeviceType.WindowsCLI, - DeviceType.MacOsCLI, - DeviceType.LinuxCLI, -]); +export const DeviceTypeMetadata: Record = { + [DeviceType.Android]: { category: "mobile", platform: "Android" }, + [DeviceType.iOS]: { category: "mobile", platform: "iOS" }, + [DeviceType.AndroidAmazon]: { category: "mobile", platform: "Amazon" }, + [DeviceType.ChromeExtension]: { category: "extension", platform: "Chrome" }, + [DeviceType.FirefoxExtension]: { category: "extension", platform: "Firefox" }, + [DeviceType.OperaExtension]: { category: "extension", platform: "Opera" }, + [DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" }, + [DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" }, + [DeviceType.SafariExtension]: { category: "extension", platform: "Safari" }, + [DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" }, + [DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" }, + [DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" }, + [DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" }, + [DeviceType.IEBrowser]: { category: "webVault", platform: "IE" }, + [DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" }, + [DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" }, + [DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" }, + [DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" }, + [DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" }, + [DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" }, + [DeviceType.UWP]: { category: "desktop", platform: "Windows UWP" }, + [DeviceType.WindowsCLI]: { category: "cli", platform: "Windows" }, + [DeviceType.MacOsCLI]: { category: "cli", platform: "macOS" }, + [DeviceType.LinuxCLI]: { category: "cli", platform: "Linux" }, + [DeviceType.SDK]: { category: "sdk", platform: "" }, + [DeviceType.Server]: { category: "server", platform: "" }, +}; From dbed5ff79b452501c561fe7f47b294221171f733 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 7 Jan 2025 15:09:43 -0500 Subject: [PATCH 3/4] [PM-16247] Autofill base common content components (#12668) * PoC implementation * build notification header components * use emotion css, and add button row components * add icons * update close button component to use new icon * add cipher components * reorganize notification component to accomodate body overflow with static footer * add action row component and fix overflow cases * fix component directory casings * add scrollbar styles * fix edit button icon display size issue * fix edit button interaction * cleanup and add dropdown menu buttons * fix footer display of full-width children * use svg brand icon in header component * refine body and footer overflow layout handling * fix fallback cipher icon sizing and other cleanup * component restructure and cleanup * restructure icon components * cleanup * re-org notification body and footer components and add typing * additional cleanup --- .../components/buttons/action-button.ts | 66 +++++ .../components/buttons/badge-button.ts | 67 +++++ .../components/buttons/close-button.ts | 39 +++ .../content/components/buttons/edit-button.ts | 60 ++++ .../components/cipher/cipher-action.ts | 31 ++ .../content/components/cipher/cipher-icon.ts | 33 +++ .../cipher/cipher-indicator-icons.ts | 35 +++ .../content/components/cipher/cipher-info.ts | 48 ++++ .../content/components/cipher/cipher-item.ts | 65 +++++ .../content/components/cipher/index.ts | 5 + .../content/components/cipher/types.ts | 44 +++ .../content/components/constants/styles.ts | 206 ++++++++++++++ .../content/components/dropdown-menu.ts | 121 ++++++++ .../content/components/icons/angle-down.ts | 27 ++ .../components/icons/brand-icon-container.ts | 19 ++ .../content/components/icons/business.ts | 46 +++ .../content/components/icons/close.ts | 27 ++ .../components/icons/exclamation-triangle.ts | 27 ++ .../content/components/icons/family.ts | 27 ++ .../content/components/icons/folder.ts | 27 ++ .../content/components/icons/globe.ts | 28 ++ .../content/components/icons/index.ts | 12 + .../content/components/icons/party-horn.ts | 174 ++++++++++++ .../content/components/icons/pencil-square.ts | 27 ++ .../content/components/icons/shield.ts | 19 ++ .../autofill/content/components/icons/user.ts | 27 ++ .../content/components/notification/body.ts | 69 +++++ .../components/notification/container.ts | 99 +++++++ .../content/components/notification/footer.ts | 42 +++ .../components/notification/header-message.ts | 25 ++ .../content/components/notification/header.ts | 61 ++++ .../content/components/rows/action-row.ts | 53 ++++ .../content/components/rows/button-row.ts | 73 +++++ .../content/components/rows/item-row.ts | 56 ++++ .../abstractions/notification-bar.ts | 13 +- package-lock.json | 265 +++++++++++++++--- package.json | 2 + 37 files changed, 2019 insertions(+), 46 deletions(-) create mode 100644 apps/browser/src/autofill/content/components/buttons/action-button.ts create mode 100644 apps/browser/src/autofill/content/components/buttons/badge-button.ts create mode 100644 apps/browser/src/autofill/content/components/buttons/close-button.ts create mode 100644 apps/browser/src/autofill/content/components/buttons/edit-button.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/cipher-action.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/cipher-icon.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/cipher-info.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/cipher-item.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/index.ts create mode 100644 apps/browser/src/autofill/content/components/cipher/types.ts create mode 100644 apps/browser/src/autofill/content/components/constants/styles.ts create mode 100644 apps/browser/src/autofill/content/components/dropdown-menu.ts create mode 100644 apps/browser/src/autofill/content/components/icons/angle-down.ts create mode 100644 apps/browser/src/autofill/content/components/icons/brand-icon-container.ts create mode 100644 apps/browser/src/autofill/content/components/icons/business.ts create mode 100644 apps/browser/src/autofill/content/components/icons/close.ts create mode 100644 apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts create mode 100644 apps/browser/src/autofill/content/components/icons/family.ts create mode 100644 apps/browser/src/autofill/content/components/icons/folder.ts create mode 100644 apps/browser/src/autofill/content/components/icons/globe.ts create mode 100644 apps/browser/src/autofill/content/components/icons/index.ts create mode 100644 apps/browser/src/autofill/content/components/icons/party-horn.ts create mode 100644 apps/browser/src/autofill/content/components/icons/pencil-square.ts create mode 100644 apps/browser/src/autofill/content/components/icons/shield.ts create mode 100644 apps/browser/src/autofill/content/components/icons/user.ts create mode 100644 apps/browser/src/autofill/content/components/notification/body.ts create mode 100644 apps/browser/src/autofill/content/components/notification/container.ts create mode 100644 apps/browser/src/autofill/content/components/notification/footer.ts create mode 100644 apps/browser/src/autofill/content/components/notification/header-message.ts create mode 100644 apps/browser/src/autofill/content/components/notification/header.ts create mode 100644 apps/browser/src/autofill/content/components/rows/action-row.ts create mode 100644 apps/browser/src/autofill/content/components/rows/button-row.ts create mode 100644 apps/browser/src/autofill/content/components/rows/item-row.ts diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts new file mode 100644 index 00000000000..a9b4742b448 --- /dev/null +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -0,0 +1,66 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { border, themes, typography, spacing } from "../constants/styles"; + +export function ActionButton({ + buttonAction, + buttonText, + disabled = false, + theme, +}: { + buttonAction: (e: Event) => void; + buttonText: string; + disabled?: boolean; + theme: Theme; +}) { + const handleButtonClick = (event: Event) => { + if (!disabled) { + buttonAction(event); + } + }; + + return html` + + `; +} + +const actionButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css` + ${typography.body2} + + user-select: none; + border: 1px solid transparent; + border-radius: ${border.radius.full}; + padding: ${spacing["1"]} ${spacing["3"]}; + width: 100%; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + font-weight: 700; + + ${disabled + ? ` + background-color: ${themes[theme].secondary["300"]}; + color: ${themes[theme].text.muted}; + ` + : ` + background-color: ${themes[theme].primary["600"]}; + cursor: pointer; + color: ${themes[theme].text.contrast}; + + :hover { + border-color: ${themes[theme].primary["700"]}; + background-color: ${themes[theme].primary["700"]}; + color: ${themes[theme].text.contrast}; + } + `} +`; diff --git a/apps/browser/src/autofill/content/components/buttons/badge-button.ts b/apps/browser/src/autofill/content/components/buttons/badge-button.ts new file mode 100644 index 00000000000..3b3b84f8166 --- /dev/null +++ b/apps/browser/src/autofill/content/components/buttons/badge-button.ts @@ -0,0 +1,67 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { border, themes, typography, spacing } from "../constants/styles"; + +export function BadgeButton({ + buttonAction, + buttonText, + disabled = false, + theme, +}: { + buttonAction: (e: Event) => void; + buttonText: string; + disabled?: boolean; + theme: Theme; +}) { + const handleButtonClick = (event: Event) => { + if (!disabled) { + buttonAction(event); + } + }; + + return html` + + `; +} + +const badgeButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css` + ${typography.helperMedium} + + user-select: none; + border-radius: ${border.radius.full}; + padding: ${spacing["1"]} ${spacing["2"]}; + max-height: fit-content; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + font-weight: 500; + + ${disabled + ? ` + border: 0.5px solid ${themes[theme].secondary["300"]}; + background-color: ${themes[theme].secondary["300"]}; + color: ${themes[theme].text.muted}; + ` + : ` + border: 0.5px solid ${themes[theme].primary["700"]}; + background-color: ${themes[theme].primary["100"]}; + cursor: pointer; + color: ${themes[theme].primary["700"]}; + + :hover { + border-color: ${themes[theme].primary["600"]}; + background-color: ${themes[theme].primary["600"]}; + color: ${themes[theme].text.contrast}; + } + `} +`; diff --git a/apps/browser/src/autofill/content/components/buttons/close-button.ts b/apps/browser/src/autofill/content/components/buttons/close-button.ts new file mode 100644 index 00000000000..c32d0c130e3 --- /dev/null +++ b/apps/browser/src/autofill/content/components/buttons/close-button.ts @@ -0,0 +1,39 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { spacing, themes } from "../constants/styles"; +import { Close as CloseIcon } from "../icons"; + +export function CloseButton({ + handleCloseNotification, + theme, +}: { + handleCloseNotification: (e: Event) => void; + theme: Theme; +}) { + return html` + + `; +} + +const closeButtonStyles = (theme: Theme) => css` + border: 1px solid transparent; + border-radius: ${spacing["1"]}; + background-color: transparent; + cursor: pointer; + width: 36px; + height: 36px; + + :hover { + border: 1px solid ${themes[theme].primary["600"]}; + } + + > svg { + width: 20px; + height: 20px; + } +`; diff --git a/apps/browser/src/autofill/content/components/buttons/edit-button.ts b/apps/browser/src/autofill/content/components/buttons/edit-button.ts new file mode 100644 index 00000000000..695cbfd3b9d --- /dev/null +++ b/apps/browser/src/autofill/content/components/buttons/edit-button.ts @@ -0,0 +1,60 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { themes, typography, spacing } from "../constants/styles"; +import { PencilSquare } from "../icons"; + +export function EditButton({ + buttonAction, + buttonText, + disabled = false, + theme, +}: { + buttonAction: (e: Event) => void; + buttonText: string; + disabled?: boolean; + theme: Theme; +}) { + return html` + + `; +} + +const editButtonStyles = ({ disabled, theme }: { disabled?: boolean; theme: Theme }) => css` + ${typography.helperMedium} + + user-select: none; + display: flex; + border: 1px solid transparent; + border-radius: ${spacing["1"]}; + background-color: transparent; + padding: ${spacing["1"]}; + max-height: fit-content; + overflow: hidden; + + ${!disabled + ? ` + cursor: pointer; + + :hover { + border-color: ${themes[theme].primary["600"]}; + } + ` + : ""} + + > svg { + width: 16px; + height: fit-content; + } +`; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts new file mode 100644 index 00000000000..2d386d34d6a --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts @@ -0,0 +1,31 @@ +import { Theme } from "@bitwarden/common/platform/enums"; + +import { BadgeButton } from "../../../content/components/buttons/badge-button"; +import { EditButton } from "../../../content/components/buttons/edit-button"; +import { NotificationTypes } from "../../../notification/abstractions/notification-bar"; + +export function CipherAction({ + handleAction = () => { + /* no-op */ + }, + notificationType, + theme, +}: { + handleAction?: (e: Event) => void; + notificationType: typeof NotificationTypes.Change | typeof NotificationTypes.Add; + theme: Theme; +}) { + return notificationType === NotificationTypes.Change + ? BadgeButton({ + buttonAction: handleAction, + // @TODO localize + buttonText: "Update item", + theme, + }) + : EditButton({ + buttonAction: handleAction, + // @TODO localize + buttonText: "Edit item", + theme, + }); +} diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts new file mode 100644 index 00000000000..73d3f7604a9 --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts @@ -0,0 +1,33 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { Globe } from "../../../content/components/icons"; + +/** + * @param {string} props.color contextual color override if no icon URI is available + * @param {string} props.size valid CSS `width` value, represents the width-basis of the graphic, with height maintaining original aspect-ratio + */ +export function CipherIcon({ + color, + size, + theme, + uri, +}: { + color: string; + size: string; + theme: Theme; + uri?: string; +}) { + const iconClass = cipherIconStyle({ width: size }); + + return uri + ? html`` + : html`${Globe({ color, theme })}`; +} + +const cipherIconStyle = ({ width }: { width: string }) => css` + width: ${width}; + height: fit-content; +`; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts new file mode 100644 index 00000000000..38b4292f8e5 --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts @@ -0,0 +1,35 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { themes } from "../../../content/components/constants/styles"; +import { Business, Family } from "../../../content/components/icons"; + +// @TODO connect data source to icon checks +// @TODO support other indicator types (attachments, etc) +export function CipherInfoIndicatorIcons({ + isBusinessOrg, + isFamilyOrg, + theme, +}: { + isBusinessOrg?: boolean; + isFamilyOrg?: boolean; + theme: Theme; +}) { + const indicatorIcons = [ + ...(isBusinessOrg ? [Business({ color: themes[theme].text.muted, theme })] : []), + ...(isFamilyOrg ? [Family({ color: themes[theme].text.muted, theme })] : []), + ]; + + return indicatorIcons.length + ? html` ${indicatorIcons} ` + : null; // @TODO null case should be handled by parent +} + +const cipherInfoIndicatorIconsStyles = css` + > svg { + width: fit-content; + height: 12px; + } +`; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-info.ts b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts new file mode 100644 index 00000000000..de374b44a97 --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts @@ -0,0 +1,48 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { themes, typography } from "../../../content/components/constants/styles"; + +import { CipherInfoIndicatorIcons } from "./cipher-indicator-icons"; +import { CipherData } from "./types"; + +// @TODO support other cipher types (card, identity, notes, etc) +export function CipherInfo({ cipher, theme }: { cipher: CipherData; theme: Theme }) { + const { name, login } = cipher; + + return html` +
+ + ${[name, CipherInfoIndicatorIcons({ theme })]} + + + ${login?.username + ? html`${login.username}` + : null} +
+ `; +} + +const cipherInfoPrimaryTextStyles = (theme: Theme) => css` + ${typography.body2} + + gap: 2px; + display: flex; + display: block; + overflow-x: hidden; + text-overflow: ellipsis; + color: ${themes[theme].text.main}; + font-weight: 500; +`; + +const cipherInfoSecondaryTextStyles = (theme: Theme) => css` + ${typography.helperMedium} + + display: block; + overflow-x: hidden; + text-overflow: ellipsis; + color: ${themes[theme].text.muted}; + font-weight: 400; +`; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts new file mode 100644 index 00000000000..651c20cac3a --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts @@ -0,0 +1,65 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { spacing, themes } from "../../../content/components/constants/styles"; +import { + NotificationType, + NotificationTypes, +} from "../../../notification/abstractions/notification-bar"; + +import { CipherAction } from "./cipher-action"; +import { CipherIcon } from "./cipher-icon"; +import { CipherInfo } from "./cipher-info"; +import { CipherData } from "./types"; + +const cipherIconWidth = "24px"; + +export function CipherItem({ + cipher, + handleAction, + notificationType, + theme = ThemeTypes.Light, +}: { + cipher: CipherData; + handleAction?: (e: Event) => void; + notificationType?: NotificationType; + theme: Theme; +}) { + const { icon } = cipher; + const uri = (icon.imageEnabled && icon.image) || undefined; + + let cipherActionButton = null; + + if (notificationType === NotificationTypes.Change || notificationType === NotificationTypes.Add) { + cipherActionButton = html`
+ ${CipherAction({ handleAction, notificationType, theme })} +
`; + } + + return html` +
+ ${CipherIcon({ color: themes[theme].text.muted, size: cipherIconWidth, theme, uri })} + ${CipherInfo({ theme, cipher })} +
+ ${cipherActionButton} + `; +} + +const cipherItemStyles = css` + gap: ${spacing["2"]}; + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: start; + + > img, + > span { + display: flex; + } + + > div { + max-width: calc(100% - ${cipherIconWidth} - ${spacing["2"]}); + } +`; diff --git a/apps/browser/src/autofill/content/components/cipher/index.ts b/apps/browser/src/autofill/content/components/cipher/index.ts new file mode 100644 index 00000000000..733ddb74b4d --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/index.ts @@ -0,0 +1,5 @@ +export * from "./cipher-action"; +export * from "./cipher-icon"; +export * from "./cipher-indicator-icons"; +export * from "./cipher-info"; +export * from "./cipher-item"; diff --git a/apps/browser/src/autofill/content/components/cipher/types.ts b/apps/browser/src/autofill/content/components/cipher/types.ts new file mode 100644 index 00000000000..24f528c5246 --- /dev/null +++ b/apps/browser/src/autofill/content/components/cipher/types.ts @@ -0,0 +1,44 @@ +const CipherTypes = { + Login: 1, + SecureNote: 2, + Card: 3, + Identity: 4, +} as const; + +type CipherType = (typeof CipherTypes)[keyof typeof CipherTypes]; + +const CipherRepromptTypes = { + None: 0, + Password: 1, +} as const; + +type CipherRepromptType = (typeof CipherRepromptTypes)[keyof typeof CipherRepromptTypes]; + +export type WebsiteIconData = { + imageEnabled: boolean; + image: string; + fallbackImage: string; + icon: string; +}; + +export type CipherData = { + id: string; + name: string; + type: CipherType; + reprompt: CipherRepromptType; + favorite: boolean; + icon: WebsiteIconData; + accountCreationFieldType?: string; + login?: { + username: string; + passkey: { + rpName: string; + userName: string; + } | null; + }; + card?: string; + identity?: { + fullName: string; + username?: string; + }; +}; diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts new file mode 100644 index 00000000000..cd6054e90ba --- /dev/null +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -0,0 +1,206 @@ +import { Theme } from "@bitwarden/common/platform/enums"; + +const lightTheme = { + transparent: { + hover: `rgb(0 0 0 / 0.02)`, + }, + shadow: `rgba(168 179 200)`, + primary: { + 100: `rgba(219, 229, 246)`, + 300: `rgba(121, 161, 233)`, + 600: `rgba(23, 93, 220)`, + 700: `rgba(26, 65, 172)`, + }, + secondary: { + 100: `rgba(230, 233, 239)`, + 300: `rgba(168, 179, 200)`, + 500: `rgba(90, 109, 145)`, + 600: `rgba(83, 99, 131)`, + 700: `rgba(63, 75, 99)`, + }, + success: { + 100: `rgba(219, 229, 246)`, + 600: `rgba(121, 161, 233)`, + 700: `rgba(26, 65, 172)`, + }, + danger: { + 100: `rgba(255, 236, 239)`, + 600: `rgba(203, 38, 58)`, + 700: `rgba(149, 27, 42)`, + }, + warning: { + 100: `rgba(255, 248, 228)`, + 600: `rgba(255, 191, 0)`, + 700: `rgba(172, 88, 0)`, + }, + info: { + 100: `rgba(219, 229, 246)`, + 600: `rgba(121, 161, 233)`, + 700: `rgba(26, 65, 172)`, + }, + art: { + primary: `rgba(2, 15, 102)`, + accent: `rgba(44, 221, 223)`, + }, + text: { + main: `rgba(27, 32, 41)`, + muted: `rgba(90, 109, 145)`, + contrast: `rgba(255, 255, 255)`, + alt2: `rgba(255, 255, 255)`, + code: `rgba(192, 17, 118)`, + }, + background: { + DEFAULT: `rgba(255, 255, 255)`, + alt: `rgba(243, 246, 249)`, + alt2: `rgba(23, 92, 219)`, + alt3: `rgba(26, 65, 172)`, + alt4: `rgba(2, 15, 102)`, + }, + brandLogo: `rgba(23, 93, 220)`, +}; + +const darkTheme = { + transparent: { + hover: `rgb(255 255 255 / 0.02)`, + }, + shadow: `rgba(0, 0, 0)`, + primary: { + 100: `rgba(26, 39, 78)`, + 300: `rgba(26, 65, 172)`, + 600: `rgba(101, 171, 255)`, + 700: `rgba(170, 195, 239)`, + }, + secondary: { + 100: `rgba(48, 57, 70)`, + 300: `rgba(82, 91, 106)`, + 500: `rgba(121, 128, 142)`, + 600: `rgba(143, 152, 166)`, + 700: `rgba(158, 167, 181)`, + }, + success: { + 100: `rgba(11, 111, 21)`, + 600: `rgba(107, 241, 120)`, + 700: `rgba(191, 236, 195)`, + }, + danger: { + 100: `rgba(149, 27, 42)`, + 600: `rgba(255, 78, 99)`, + 700: `rgba(255, 236, 239)`, + }, + warning: { + 100: `rgba(172, 88, 0)`, + 600: `rgba(255, 191, 0)`, + 700: `rgba(255, 248, 228)`, + }, + info: { + 100: `rgba(26, 65, 172)`, + 600: `rgba(121, 161, 233)`, + 700: `rgba(219, 229, 246)`, + }, + art: { + primary: `rgba(243, 246, 249)`, + accent: `rgba(44, 221, 233)`, + }, + text: { + main: `rgba(243, 246, 249)`, + muted: `rgba(136, 152, 181)`, + contrast: `rgba(18 26 39)`, + alt2: `rgba(255, 255, 255)`, + code: `rgba(255, 143, 208)`, + }, + background: { + DEFAULT: `rgba(32, 39, 51)`, + alt: `rgba(18, 26, 39)`, + alt2: `rgba(47, 52, 61)`, + alt3: `rgba(48, 57, 70)`, + alt4: `rgba(18, 26, 39)`, + }, + brandLogo: `rgba(255, 255, 255)`, +}; + +export const themes = { + light: lightTheme, + dark: darkTheme, + + // For compatibility + system: lightTheme, + nord: lightTheme, + solarizedDark: darkTheme, +}; + +export const spacing = { + 4: `16px`, + 3: `12px`, + 2: `8px`, + "1.5": `6px`, + 1: `4px`, +}; + +export const border = { + radius: { + large: `8px`, + full: `9999px`, + }, +}; + +export const typography = { + body1: ` + line-height: 24px; + font-family: "DM Sans", sans-serif; + font-size: 16px; + `, + body2: ` + line-height: 20px; + font-family: "DM Sans", sans-serif; + font-size: 14px; + `, + helperMedium: ` + line-height: 16px; + font-family: "DM Sans", sans-serif; + font-size: 12px; + `, +}; + +export const ruleNames = { + fill: "fill", + stroke: "stroke", +} as const; + +type RuleName = (typeof ruleNames)[keyof typeof ruleNames]; + +/* + * `color` is an intentionally generic name here, since either fill or stroke may apply, due to + * inconsistent SVG construction. This consequently precludes dynamic multi-colored icons here. + */ +export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fill) => ` + ${rule}: ${color}; +`; + +export function scrollbarStyles(theme: Theme) { + return { + default: ` + /* FireFox & Chrome support */ + scrollbar-color: ${themes[theme].secondary["500"]} ${themes[theme].background.alt}; + `, + safari: ` + /* Safari Support */ + ::-webkit-scrollbar { + overflow: auto; + } + ::-webkit-scrollbar-thumb { + border-width: 4px; + border-style: solid; + border-radius: 0.5rem; + border-color: transparent; + background-clip: content-box; + background-color: ${themes[theme].secondary["500"]}; + } + ::-webkit-scrollbar-track { + ${themes[theme].background.alt}; + } + ::-webkit-scrollbar-thumb:hover { + ${themes[theme].secondary["600"]}; + } + `, + }; +} diff --git a/apps/browser/src/autofill/content/components/dropdown-menu.ts b/apps/browser/src/autofill/content/components/dropdown-menu.ts new file mode 100644 index 00000000000..3e3874b37d7 --- /dev/null +++ b/apps/browser/src/autofill/content/components/dropdown-menu.ts @@ -0,0 +1,121 @@ +import { css } from "@emotion/css"; +import { html, TemplateResult } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { border, themes, typography, spacing } from "./constants/styles"; +import { AngleDown } from "./icons"; + +export function DropdownMenu({ + buttonText, + icon, + disabled = false, + selectAction, + theme, +}: { + selectAction?: (e: Event) => void; + buttonText: string; + icon?: TemplateResult; + disabled?: boolean; + theme: Theme; +}) { + // @TODO placeholder/will not work; make stateful + const showDropdown = false; + const handleButtonClick = (event: Event) => { + // if (!disabled) { + // // show dropdown + // showDropdown = !showDropdown; + // this.requestUpdate(); + // } + }; + + const dropdownMenuItems: TemplateResult[] = []; + + return html` +
+ + ${showDropdown + ? html`
${dropdownMenuItems}
` + : null} +
+ `; +} + +const iconSize = "15px"; + +const dropdownContainerStyles = css` + display: flex; + + > div, + > button { + width: 100%; + } +`; + +const dropdownButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css` + ${typography.body2} + + font-weight: 400; + gap: ${spacing["1.5"]}; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + border-radius: ${border.radius.full}; + padding: ${spacing["1"]} ${spacing["2"]}; + max-height: fit-content; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + + > svg { + max-width: ${iconSize}; + height: fit-content; + } + + ${disabled + ? ` + border: 1px solid ${themes[theme].secondary["300"]}; + background-color: ${themes[theme].secondary["300"]}; + color: ${themes[theme].text.muted}; + ` + : ` + border: 1px solid ${themes[theme].text.muted}; + background-color: transparent; + cursor: pointer; + color: ${themes[theme].text.muted}; + + :hover { + border-color: ${themes[theme].secondary["700"]}; + background-color: ${themes[theme].secondary["100"]}; + } + `} +`; + +const dropdownButtonTextStyles = css` + max-width: calc(100% - ${iconSize} - ${iconSize}); + overflow-x: hidden; + text-overflow: ellipsis; +`; + +const dropdownMenuStyles = ({ theme }: { theme: Theme }) => css` + color: ${themes[theme].text.main}; + border: 1px solid ${themes[theme].secondary["500"]}; + border-radius: 0.5rem; + background-clip: padding-box; + background-color: ${themes[theme].background.DEFAULT}; + padding: 0.25rem 0.75rem; + position: absolute; + overflow-y: auto; +`; diff --git a/apps/browser/src/autofill/content/components/icons/angle-down.ts b/apps/browser/src/autofill/content/components/icons/angle-down.ts new file mode 100644 index 00000000000..4b85319c18a --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/angle-down.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function AngleDown({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts b/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts new file mode 100644 index 00000000000..8df68d79b6e --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts @@ -0,0 +1,19 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { Shield } from "./shield"; + +export function BrandIconContainer({ iconLink, theme }: { iconLink?: URL; theme: Theme }) { + const Icon = html`
${Shield({ theme })}
`; + + return iconLink ? html`${Icon}` : Icon; +} + +const brandIconContainerStyles = css` + > svg { + width: 20px; + height: fit-content; + } +`; diff --git a/apps/browser/src/autofill/content/components/icons/business.ts b/apps/browser/src/autofill/content/components/icons/business.ts new file mode 100644 index 00000000000..547ee82b547 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/business.ts @@ -0,0 +1,46 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Business({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/close.ts b/apps/browser/src/autofill/content/components/icons/close.ts new file mode 100644 index 00000000000..c94a4b20a6a --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/close.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Close({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts new file mode 100644 index 00000000000..bcc7b3d5432 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function ExclamationTriangle({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/family.ts b/apps/browser/src/autofill/content/components/icons/family.ts new file mode 100644 index 00000000000..33e2e422ced --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/family.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Family({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/folder.ts b/apps/browser/src/autofill/content/components/icons/folder.ts new file mode 100644 index 00000000000..7e1f8f197f6 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/folder.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Folder({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/globe.ts b/apps/browser/src/autofill/content/components/icons/globe.ts new file mode 100644 index 00000000000..6697fa93b70 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/globe.ts @@ -0,0 +1,28 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Globe({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts new file mode 100644 index 00000000000..992b034afa7 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -0,0 +1,12 @@ +export { AngleDown } from "./angle-down"; +export { BrandIconContainer } from "./brand-icon-container"; +export { Business } from "./business"; +export { Close } from "./close"; +export { ExclamationTriangle } from "./exclamation-triangle"; +export { Family } from "./family"; +export { Folder } from "./folder"; +export { Globe } from "./globe"; +export { PartyHorn } from "./party-horn"; +export { PencilSquare } from "./pencil-square"; +export { Shield } from "./shield"; +export { User } from "./user"; diff --git a/apps/browser/src/autofill/content/components/icons/party-horn.ts b/apps/browser/src/autofill/content/components/icons/party-horn.ts new file mode 100644 index 00000000000..dc2144b524f --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/party-horn.ts @@ -0,0 +1,174 @@ +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +// This icon has static multi-colors for each theme +export function PartyHorn({ theme }: { theme: Theme }) { + if (theme === ThemeTypes.Dark) { + return html` + + + + + + + + + + + + + + `; + } + + return html` + + + + + + + + + + + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/pencil-square.ts b/apps/browser/src/autofill/content/components/icons/pencil-square.ts new file mode 100644 index 00000000000..45a8429f883 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/pencil-square.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function PencilSquare({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/shield.ts b/apps/browser/src/autofill/content/components/icons/shield.ts new file mode 100644 index 00000000000..5ffd953e869 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/shield.ts @@ -0,0 +1,19 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Shield({ color, theme }: { color?: string; theme: Theme }) { + const shapeColor = color || themes[theme].brandLogo; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/user.ts b/apps/browser/src/autofill/content/components/icons/user.ts new file mode 100644 index 00000000000..6babcfa39a9 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/user.ts @@ -0,0 +1,27 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function User({ + color, + disabled, + theme, +}: { + color?: string; + disabled?: boolean; + theme: Theme; +}) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/notification/body.ts b/apps/browser/src/autofill/content/components/notification/body.ts new file mode 100644 index 00000000000..6a3ed2e5d1e --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/body.ts @@ -0,0 +1,69 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { NotificationType } from "../../../notification/abstractions/notification-bar"; +import { CipherItem } from "../cipher"; +import { CipherData } from "../cipher/types"; +import { scrollbarStyles, spacing, themes, typography } from "../constants/styles"; +import { ItemRow } from "../rows/item-row"; + +export const componentClassPrefix = "notification-body"; + +const { css } = createEmotion({ + key: componentClassPrefix, +}); + +export function NotificationBody({ + ciphers, + notificationType, + theme = ThemeTypes.Light, +}: { + ciphers: CipherData[]; + customClasses?: string[]; + notificationType?: NotificationType; + theme: Theme; +}) { + // @TODO get client vendor from context + const isSafari = false; + + return html` +
+ ${ciphers.map((cipher) => + ItemRow({ + theme, + children: CipherItem({ + cipher, + notificationType, + theme, + handleAction: () => { + // @TODO connect update or edit actions to handler + }, + }), + }), + )} +
+ `; +} + +const notificationBodyStyles = ({ isSafari, theme }: { isSafari: boolean; theme: Theme }) => css` + ${typography.body1} + + gap: ${spacing["1.5"]}; + display: flex; + flex-flow: column; + background-color: ${themes[theme].background.alt}; + max-height: 123px; + overflow-x: hidden; + overflow-y: auto; + white-space: nowrap; + color: ${themes[theme].text.main}; + font-weight: 400; + + :last-child { + border-radius: 0 0 ${spacing["4"]} ${spacing["4"]}; + } + + ${isSafari ? scrollbarStyles(theme).safari : scrollbarStyles(theme).default} +`; diff --git a/apps/browser/src/autofill/content/components/notification/container.ts b/apps/browser/src/autofill/content/components/notification/container.ts new file mode 100644 index 00000000000..0cce066cf3a --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/container.ts @@ -0,0 +1,99 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { + NotificationBarIframeInitData, + NotificationTypes, + NotificationType, +} from "../../../notification/abstractions/notification-bar"; +import { createAutofillOverlayCipherDataMock } from "../../../spec/autofill-mocks"; +import { CipherData } from "../cipher/types"; +import { themes, spacing } from "../constants/styles"; + +import { NotificationBody, componentClassPrefix as notificationBodyClassPrefix } from "./body"; +import { NotificationFooter } from "./footer"; +import { + NotificationHeader, + componentClassPrefix as notificationHeaderClassPrefix, +} from "./header"; + +export function NotificationContainer({ + handleCloseNotification, + i18n, + theme = ThemeTypes.Light, + type, +}: NotificationBarIframeInitData & { handleCloseNotification: (e: Event) => void } & { + i18n: { [key: string]: string }; + type: NotificationType; // @TODO typing override for generic `NotificationBarIframeInitData.type` +}) { + const headerMessage = getHeaderMessage(i18n, type); + const showBody = true; + + // @TODO remove mock ciphers for development + const ciphers = [ + createAutofillOverlayCipherDataMock(1), + { ...createAutofillOverlayCipherDataMock(2), icon: { imageEnabled: false } }, + { + ...createAutofillOverlayCipherDataMock(3), + icon: { imageEnabled: true, image: "https://localhost:8443/icons/webtests.dev/icon.png" }, + }, + ] as CipherData[]; + + return html` +
+ ${NotificationHeader({ + handleCloseNotification, + standalone: showBody, + message: headerMessage, + theme, + })} + ${showBody + ? NotificationBody({ + ciphers, + notificationType: type, + theme, + }) + : null} + ${NotificationFooter({ + theme, + notificationType: type, + })} +
+ `; +} + +const notificationContainerStyles = (theme: Theme) => css` + position: absolute; + right: 20px; + border: 1px solid ${themes[theme].secondary["300"]}; + border-radius: ${spacing["4"]}; + box-shadow: -2px 4px 6px 0px #0000001a; + background-color: ${themes[theme].background.alt}; + width: 400px; + + [class*="${notificationHeaderClassPrefix}-"] { + border-radius: ${spacing["4"]} ${spacing["4"]} 0 0; + } + + [class*="${notificationBodyClassPrefix}-"] { + margin: ${spacing["3"]} 0 ${spacing["1.5"]} ${spacing["3"]}; + padding-right: ${spacing["3"]}; + } +`; + +function getHeaderMessage(i18n: { [key: string]: string }, type?: NotificationType) { + switch (type) { + case NotificationTypes.Add: + return i18n.saveAsNewLoginAction; + case NotificationTypes.Change: + return i18n.updateLoginPrompt; + case NotificationTypes.Unlock: + return ""; + case NotificationTypes.FilelessImport: + return ""; + default: + return undefined; + } +} diff --git a/apps/browser/src/autofill/content/components/notification/footer.ts b/apps/browser/src/autofill/content/components/notification/footer.ts new file mode 100644 index 00000000000..91a72dc9aab --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/footer.ts @@ -0,0 +1,42 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { + NotificationType, + NotificationTypes, +} from "../../../notification/abstractions/notification-bar"; +import { spacing, themes } from "../constants/styles"; +import { ActionRow } from "../rows/action-row"; +import { ButtonRow } from "../rows/button-row"; + +export function NotificationFooter({ + notificationType, + theme, +}: { + notificationType?: NotificationType; + theme: Theme; +}) { + const isChangeNotification = notificationType === NotificationTypes.Change; + // @TODO localize + const saveNewItemText = "Save as new login"; + + return html` +
+ ${isChangeNotification + ? ActionRow({ itemText: saveNewItemText, handleAction: () => {}, theme }) + : ButtonRow({ theme })} +
+ `; +} + +const notificationFooterStyles = ({ theme }: { theme: Theme }) => css` + display: flex; + background-color: ${themes[theme].background.alt}; + padding: 0 ${spacing[3]} ${spacing[3]} ${spacing[3]}; + + :last-child { + border-radius: 0 0 ${spacing["4"]} ${spacing["4"]}; + } +`; diff --git a/apps/browser/src/autofill/content/components/notification/header-message.ts b/apps/browser/src/autofill/content/components/notification/header-message.ts new file mode 100644 index 00000000000..ccfa58b8970 --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/header-message.ts @@ -0,0 +1,25 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { themes } from "../constants/styles"; + +export function NotificationHeaderMessage({ message, theme }: { message: string; theme: Theme }) { + return html` + ${message} + `; +} + +const notificationHeaderMessageStyles = (theme: Theme) => css` + flex-grow: 1; + overflow-x: hidden; + text-align: left; + text-overflow: ellipsis; + line-height: 28px; + white-space: nowrap; + color: ${themes[theme].text.main}; + font-family: "DM Sans", sans-serif; + font-size: 18px; + font-weight: 600; +`; diff --git a/apps/browser/src/autofill/content/components/notification/header.ts b/apps/browser/src/autofill/content/components/notification/header.ts new file mode 100644 index 00000000000..85f6e48cd5d --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/header.ts @@ -0,0 +1,61 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { CloseButton } from "../buttons/close-button"; +import { themes } from "../constants/styles"; +import { BrandIconContainer } from "../icons/brand-icon-container"; + +import { NotificationHeaderMessage } from "./header-message"; + +export const componentClassPrefix = "notification-header"; + +const { css } = createEmotion({ + key: componentClassPrefix, +}); + +export function NotificationHeader({ + message, + standalone, + theme = ThemeTypes.Light, + handleCloseNotification, +}: { + message?: string; + standalone: boolean; + theme: Theme; + handleCloseNotification: (e: Event) => void; +}) { + const showIcon = true; + const isDismissable = true; + + return html` +
+ ${showIcon ? BrandIconContainer({ theme }) : null} + ${message ? NotificationHeaderMessage({ message, theme }) : null} + ${isDismissable ? CloseButton({ handleCloseNotification, theme }) : null} +
+ `; +} + +const notificationHeaderStyles = ({ + standalone, + theme, +}: { + standalone: boolean; + theme: Theme; +}) => css` + gap: 8px; + display: flex; + align-items: center; + justify-content: flex-start; + background-color: ${themes[theme].background.alt}; + padding: 12px 16px 8px 16px; + white-space: nowrap; + + ${standalone + ? css` + border-bottom: 0.5px solid ${themes[theme].secondary["300"]}; + ` + : css``} +`; diff --git a/apps/browser/src/autofill/content/components/rows/action-row.ts b/apps/browser/src/autofill/content/components/rows/action-row.ts new file mode 100644 index 00000000000..ad58411baf4 --- /dev/null +++ b/apps/browser/src/autofill/content/components/rows/action-row.ts @@ -0,0 +1,53 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { spacing, themes, typography } from "../../../content/components/constants/styles"; + +export function ActionRow({ + handleAction, + itemText, + theme = ThemeTypes.Light, +}: { + itemText: string; + handleAction?: (e: Event) => void; + theme: Theme; +}) { + return html` + + `; +} + +const actionRowStyles = (theme: Theme) => css` + ${typography.body2} + + user-select: none; + border-width: 0 0 0.5px 0; + border-style: solid; + border-radius: ${spacing["2"]}; + border-color: ${themes[theme].secondary["300"]}; + background-color: ${themes[theme].background.DEFAULT}; + cursor: pointer; + padding: ${spacing["2"]} ${spacing["3"]}; + width: 100%; + min-height: 40px; + text-align: left; + color: ${themes[theme].primary["600"]}; + font-weight: 700; + + > span { + display: block; + width: calc(100% - 5px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :hover { + background-color: ${themes[theme].primary["100"]}; + color: ${themes[theme].primary["600"]}; + } +`; diff --git a/apps/browser/src/autofill/content/components/rows/button-row.ts b/apps/browser/src/autofill/content/components/rows/button-row.ts new file mode 100644 index 00000000000..ce14a242e97 --- /dev/null +++ b/apps/browser/src/autofill/content/components/rows/button-row.ts @@ -0,0 +1,73 @@ +import { css } from "@emotion/css"; +import { html, TemplateResult } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { ActionButton } from "../../../content/components/buttons/action-button"; +import { spacing, themes } from "../../../content/components/constants/styles"; +import { Folder, User } from "../../../content/components/icons"; +import { DropdownMenu } from "../dropdown-menu"; + +export function ButtonRow({ theme }: { theme: Theme }) { + return html` +
+ ${[ + ActionButton({ + buttonAction: () => {}, + buttonText: "Action Button", + theme, + }), + DropdownContainer({ + children: [ + DropdownMenu({ + buttonText: "You", + icon: User({ color: themes[theme].text.muted, theme }), + theme, + }), + DropdownMenu({ + buttonText: "Folder", + icon: Folder({ color: themes[theme].text.muted, theme }), + disabled: true, + theme, + }), + ], + }), + ]} +
+ `; +} + +function DropdownContainer({ children }: { children: TemplateResult[] }) { + return html`
${children}
`; +} + +const buttonRowStyles = css` + gap: 16px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-height: 52px; + white-space: nowrap; + + > button { + max-width: min-content; + flex: 1 1 50%; + } + + > div { + flex: 1 1 min-content; + } +`; + +const dropdownContainerStyles = css` + gap: 8px; + display: flex; + align-items: center; + justify-content: flex-end; + overflow: hidden; + + > div { + min-width: calc(50% - ${spacing["1.5"]}); + } +`; diff --git a/apps/browser/src/autofill/content/components/rows/item-row.ts b/apps/browser/src/autofill/content/components/rows/item-row.ts new file mode 100644 index 00000000000..da00fd276ab --- /dev/null +++ b/apps/browser/src/autofill/content/components/rows/item-row.ts @@ -0,0 +1,56 @@ +import { css } from "@emotion/css"; +import { html, TemplateResult } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { spacing, themes, typography } from "../../../content/components/constants/styles"; + +export function ItemRow({ + theme = ThemeTypes.Light, + children, +}: { + theme: Theme; + children: TemplateResult | TemplateResult[]; +}) { + return html`
${children}
`; +} + +export const itemRowStyles = ({ theme }: { theme: Theme }) => css` + ${typography.body1} + + gap: ${spacing["2"]}; + display: flex; + align-items: center; + justify-content: space-between; + border-width: 0 0 0.5px 0; + border-style: solid; + border-radius: ${spacing["2"]}; + border-color: ${themes[theme].secondary["300"]}; + background-color: ${themes[theme].background.DEFAULT}; + padding: ${spacing["2"]} ${spacing["3"]}; + min-height: min-content; + max-height: 52px; + overflow-x: hidden; + white-space: nowrap; + color: ${themes[theme].text.main}; + font-weight: 400; + + > div { + :first-child { + flex: 3 3 75%; + min-width: 25%; + } + + :not(:first-child) { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-end; + max-width: 25%; + + > button { + max-width: min-content; + } + } + } +`; diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index 425d53783e1..2e38adacb32 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -1,7 +1,16 @@ import { Theme } from "@bitwarden/common/platform/enums"; +const NotificationTypes = { + Add: "add", + Change: "change", + Unlock: "unlock", + FilelessImport: "fileless-import", +} as const; + +type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]; + type NotificationBarIframeInitData = { - type?: string; + type?: string; // @TODO use `NotificationType` isVaultLocked?: boolean; theme?: Theme; removeIndividualVault?: boolean; @@ -24,6 +33,8 @@ type NotificationBarWindowMessageHandlers = { }; export { + NotificationTypes, + NotificationType, NotificationBarIframeInitData, NotificationBarWindowMessage, NotificationBarWindowMessageHandlers, diff --git a/package-lock.json b/package-lock.json index c60d71881a5..7994c8b0c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@angular/router": "18.2.13", "@bitwarden/sdk-internal": "0.2.0-main.38", "@electron/fuses": "1.8.0", + "@emotion/css": "11.13.5", "@koa/multer": "3.0.2", "@koa/router": "13.1.0", "@microsoft/signalr": "8.0.7", @@ -50,6 +51,7 @@ "koa": "2.15.3", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", + "lit": "3.2.1", "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "1.4.5-lts.1", @@ -4344,7 +4346,6 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -5650,6 +5651,109 @@ "node": "*" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", @@ -5836,6 +5940,50 @@ "lit": "^2.1.3" } }, + "node_modules/@figspec/components/node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/@figspec/components/node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/@figspec/components/node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/@figspec/components/node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/@figspec/react": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@figspec/react/-/react-1.0.3.tgz", @@ -6971,17 +7119,15 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@lit/reactive-element": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", - "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", + "integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.0.0" + "@lit-labs/ssr-dom-shim": "^1.2.0" } }, "node_modules/@lmdb/lmdb-darwin-arm64": { @@ -9702,7 +9848,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "dev": true, "license": "MIT" }, "node_modules/@types/plist": { @@ -9871,7 +10016,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -11961,6 +12105,46 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.11", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", @@ -12909,7 +13093,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13812,7 +13995,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -14333,7 +14515,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -15560,7 +15741,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -15809,7 +15989,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -17241,6 +17420,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -18899,7 +19084,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -18916,7 +19100,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -19216,7 +19399,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-bigint": { @@ -19292,7 +19474,6 @@ "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -22116,7 +22297,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -22459,34 +22639,31 @@ } }, "node_modules/lit": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", - "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", - "dev": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz", + "integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==", "license": "BSD-3-Clause", "dependencies": { - "@lit/reactive-element": "^1.6.0", - "lit-element": "^3.3.0", - "lit-html": "^2.8.0" + "@lit/reactive-element": "^2.0.4", + "lit-element": "^4.1.0", + "lit-html": "^3.2.0" } }, "node_modules/lit-element": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", - "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz", + "integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.0", - "@lit/reactive-element": "^1.3.0", - "lit-html": "^2.8.0" + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.0.4", + "lit-html": "^3.2.0" } }, "node_modules/lit-html": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", - "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", - "dev": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz", + "integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" @@ -26075,7 +26252,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -26088,7 +26264,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -26107,7 +26282,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/parse-node-version": { @@ -26371,7 +26545,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -26408,7 +26581,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -27759,7 +27931,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regenerator-transform": { @@ -28058,7 +28229,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", @@ -29954,6 +30124,12 @@ "webpack": "^5.27.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -30016,7 +30192,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 9922b6d7bac..0a78d370d26 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "@angular/router": "18.2.13", "@bitwarden/sdk-internal": "0.2.0-main.38", "@electron/fuses": "1.8.0", + "@emotion/css": "11.13.5", "@koa/multer": "3.0.2", "@koa/router": "13.1.0", "@microsoft/signalr": "8.0.7", @@ -180,6 +181,7 @@ "koa": "2.15.3", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", + "lit": "3.2.1", "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "1.4.5-lts.1", From 5cb31f37e922864a82f2c9d1f1b72f4f62a6d34a Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 7 Jan 2025 15:10:42 -0500 Subject: [PATCH 4/4] [PM-16824] update new device verification notice page one so learn more link opens in browser from desktop (#12731) --- .../new-device-verification-notice-page-one.component.html | 7 +------ .../new-device-verification-notice-page-one.component.ts | 6 ++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.html b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.html index ddff560fd00..9d7808379d3 100644 --- a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.html +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.html @@ -1,12 +1,7 @@

{{ "newDeviceVerificationNoticeContentPage1" | i18n }} - + {{ "learnMore" | i18n }}.

diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts index 8127c368046..8db923fec88 100644 --- a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts @@ -121,4 +121,10 @@ export class NewDeviceVerificationNoticePageOneComponent implements OnInit, Afte await this.router.navigate(["/vault"]); }; + + navigateToNewDeviceVerificationHelp(event: Event) { + event.preventDefault(); + + this.platformUtilsService.launchUri("https://bitwarden.com/help/new-device-verification/"); + } }