From 3558db0e0cc63cdac9ebcd3db21a0bafa7358517 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Fri, 2 Jan 2026 09:48:39 -0500 Subject: [PATCH 001/101] Revert "[PM-29418] Fix SSH list not working while locked (#17866)" (#18171) This reverts commit 24dcbb48c61309da382a6a33b96bdee9785e1295. --- .../desktop_native/core/src/ssh_agent/mod.rs | 86 +------------------ 1 file changed, 1 insertion(+), 85 deletions(-) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 16cf778b575..8ba64618ffa 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -226,7 +226,7 @@ impl BitwardenDesktopAgent { keystore.0.write().expect("RwLock is not poisoned").clear(); self.needs_unlock - .store(false, std::sync::atomic::Ordering::Relaxed); + .store(true, std::sync::atomic::Ordering::Relaxed); for (key, name, cipher_id) in new_keys.iter() { match parse_key_safe(key) { @@ -307,87 +307,3 @@ fn parse_key_safe(pem: &str) -> Result Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_agent() -> ( - BitwardenDesktopAgent, - tokio::sync::mpsc::Receiver, - tokio::sync::broadcast::Sender<(u32, bool)>, - ) { - let (tx, rx) = tokio::sync::mpsc::channel(10); - let (response_tx, response_rx) = tokio::sync::broadcast::channel(10); - let agent = BitwardenDesktopAgent::new(tx, Arc::new(Mutex::new(response_rx))); - (agent, rx, response_tx) - } - - const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACCWETEIh/JX+ZaK0Xlg5xZ9QIfjiKD2Qs57PjhRY45trwAAAIhqmvSbapr0 -mwAAAAtzc2gtZWQyNTUxOQAAACCWETEIh/JX+ZaK0Xlg5xZ9QIfjiKD2Qs57PjhRY45trw -AAAEAHVflTgR/OEl8mg9UEKcO7SeB0FH4AiaUurhVfBWT4eZYRMQiH8lf5lorReWDnFn1A -h+OIoPZCzns+OFFjjm2vAAAAAAECAwQF ------END OPENSSH PRIVATE KEY-----"; - - #[tokio::test] - async fn test_needs_unlock_initial_state() { - let (agent, _rx, _response_tx) = create_test_agent(); - - // Initially, needs_unlock should be true - assert!(agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } - - #[tokio::test] - async fn test_needs_unlock_after_set_keys() { - let (mut agent, _rx, _response_tx) = create_test_agent(); - agent - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); - - // Set keys should set needs_unlock to false - let keys = vec![( - TEST_ED25519_KEY.to_string(), - "test_key".to_string(), - "cipher_id".to_string(), - )]; - - agent.set_keys(keys).unwrap(); - - assert!(!agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } - - #[tokio::test] - async fn test_needs_unlock_after_clear_keys() { - let (mut agent, _rx, _response_tx) = create_test_agent(); - agent - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); - - // Set keys first - let keys = vec![( - TEST_ED25519_KEY.to_string(), - "test_key".to_string(), - "cipher_id".to_string(), - )]; - agent.set_keys(keys).unwrap(); - - // Verify needs_unlock is false - assert!(!agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - - // Clear keys should set needs_unlock back to true - agent.clear_keys().unwrap(); - - // Verify needs_unlock is true - assert!(agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } -} From 738df45f38eaeb9f561eb53b72d618a5479e90c0 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 5 Jan 2026 11:07:37 +0100 Subject: [PATCH 002/101] Add input for forcing active state on group & disable toggle (#18043) --- .../src/navigation/nav-group.component.html | 1 + .../src/navigation/nav-group.component.ts | 8 +++++++- .../src/navigation/nav-group.stories.ts | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index a5cb1d5a6b9..1790fea179a 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -14,6 +14,7 @@ [ariaLabel]="ariaLabel()" [hideActiveStyles]="parentHideActiveStyles()" [ariaCurrentWhenActive]="ariaCurrent()" + [forceActiveStyles]="forceActiveStyles()" > - + + + `, imports: [ButtonModule, LayoutComponent], @@ -63,13 +67,29 @@ class StoryDialogComponent { }, }); } + + openSmallDrawer() { + this.dialogService.openDrawer(SmallDrawerContentComponent, { + data: { + animal: "panda", + }, + }); + } + + openLargeDrawer() { + this.dialogService.openDrawer(LargeDrawerContentComponent, { + data: { + animal: "panda", + }, + }); + } } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + Dialog body text goes here.
@@ -100,7 +120,6 @@ class StoryDialogContentComponent { template: ` Dialog body text goes here. @@ -125,6 +144,64 @@ class NonDismissableContentComponent { } } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + template: ` + + + Dialog body text goes here. +
+ Animal: {{ animal }} +
+ + + + +
+ `, + imports: [DialogModule, ButtonModule], +}) +class SmallDrawerContentComponent { + dialogRef = inject(DialogRef); + private data = inject(DIALOG_DATA); + + get animal() { + return this.data?.animal; + } +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + template: ` + + + Dialog body text goes here. +
+ Animal: {{ animal }} +
+ + + + +
+ `, + imports: [DialogModule, ButtonModule], +}) +class LargeDrawerContentComponent { + dialogRef = inject(DialogRef); + private data = inject(DIALOG_DATA); + + get animal() { + return this.data?.animal; + } +} + export default { title: "Component Library/Dialogs/Service", component: StoryDialogComponent, @@ -206,3 +283,21 @@ export const Drawer: Story = { await userEvent.click(button); }, }; + +export const DrawerSmall: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[3]; + await userEvent.click(button); + }, +}; + +export const DrawerLarge: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[4]; + await userEvent.click(button); + }, +}; diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 22aa99c44cb..58364dfd045 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -2,7 +2,7 @@
("default"); + readonly dialogSize = input("default"); /** * Title to show in the dialog's header @@ -100,21 +114,31 @@ export class DialogComponent { private readonly animationCompleted = signal(false); + protected readonly width = computed(() => { + const size = this.dialogSize() ?? "default"; + const isDrawer = this.dialogRef?.isDrawer; + + if (isDrawer) { + return drawerSizeToWidth[size]; + } + + return dialogSizeToWidth[size]; + }); + protected readonly classes = computed(() => { // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"]; - const sizeClasses = this.dialogRef?.isDrawer - ? ["tw-h-full", "md:tw-w-[23rem]"] - : ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"]; + const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"]; + const size = this.dialogSize() ?? "default"; const animationClasses = this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer ? [] - : this.dialogSize() === "small" + : size === "small" ? ["tw-animate-slide-down"] : ["tw-animate-slide-up", "md:tw-animate-slide-down"]; - return [...baseClasses, this.width, ...sizeClasses, ...animationClasses]; + return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses]; }); handleEsc(event: Event) { @@ -124,20 +148,6 @@ export class DialogComponent { } } - get width() { - switch (this.dialogSize()) { - case "small": { - return "md:tw-max-w-sm"; - } - case "large": { - return "md:tw-max-w-3xl"; - } - default: { - return "md:tw-max-w-xl"; - } - } - } - onAnimationEnd() { this.animationCompleted.set(true); } From cf285abd3d2a69229e38e13903e68e14f2d5bffd Mon Sep 17 00:00:00 2001 From: Isaac Ivins Date: Mon, 5 Jan 2026 09:37:24 -0500 Subject: [PATCH 009/101] Feature/pm 25865 migrate send list desktop migration (#18008) This PR moves the Desktop Send list UI into a shared library component and updates the Desktop Send v2 component to use modern Angular patterns (Signals, OnPush, no manual subscriptions) --- .../app/tools/send-v2/send-v2.component.html | 154 +++---- .../tools/send-v2/send-v2.component.spec.ts | 310 +++----------- .../app/tools/send-v2/send-v2.component.ts | 386 +++++++++--------- apps/desktop/src/locales/en/messages.json | 14 + apps/desktop/src/scss/migration.scss | 14 + libs/tools/send/send-ui/src/index.ts | 2 + .../new-send-dropdown-v2.component.html | 19 + .../new-send-dropdown-v2.component.spec.ts | 261 ++++++++++++ .../new-send-dropdown-v2.component.ts | 59 +++ .../src/send-list/send-list.component.html | 31 ++ .../src/send-list/send-list.component.spec.ts | 89 ++++ .../src/send-list/send-list.component.ts | 105 +++++ 12 files changed, 902 insertions(+), 542 deletions(-) create mode 100644 libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html create mode 100644 libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts create mode 100644 libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts create mode 100644 libs/tools/send/send-ui/src/send-list/send-list.component.html create mode 100644 libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts create mode 100644 libs/tools/send/send-ui/src/send-list/send-list.component.ts diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index 20cac15138a..659e4be9c5b 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -1,110 +1,64 @@
-
+
-
- + +
+

{{ "send" | i18n }}

+ @if (!disableSend()) { + + }
-
- - - -

{{ "noItemsInList" | i18n }}

-
-
-
- -
- + @if (isFamiliesPlan) {

{{ "paymentChargedWithTrial" | i18n }} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a824e850db6..34362b4be3e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -9,7 +9,7 @@ import { signal, viewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { debounceTime, @@ -22,6 +22,7 @@ import { combineLatest, map, shareReplay, + defer, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -35,7 +36,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { CartSummaryComponent } from "@bitwarden/pricing"; +import { Cart, CartSummaryComponent } from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -118,23 +119,48 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected readonly selectedPlan = signal(null); protected readonly loading = signal(true); protected readonly upgradeToMessage = signal(""); - // Cart Summary data - protected readonly passwordManager = computed(() => { - if (!this.selectedPlan()) { - return { name: "", cost: 0, quantity: 0, cadence: "year" as const }; - } - - return { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan()!.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year" as const, - }; - }); protected hasEnoughAccountCredit$!: Observable; private pricingTiers$!: Observable; - protected estimatedTax$!: Observable; + + // Use defer to lazily create the observable when subscribed to + protected estimatedTax$ = defer(() => + this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + switchMap(() => this.refreshSalesTax$()), + ), + ); + + // Convert estimatedTax$ to signal for use in computed cart + protected readonly estimatedTax = toSignal(this.estimatedTax$, { + initialValue: this.INITIAL_TAX_VALUE, + }); + + // Cart Summary data + protected readonly cart = computed(() => { + if (!this.selectedPlan()) { + return { + passwordManager: { + seats: { name: "", cost: 0, quantity: 0 }, + }, + cadence: "annually", + estimatedTax: 0, + }; + } + + return { + passwordManager: { + seats: { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, + quantity: 1, + }, + }, + cadence: "annually", + estimatedTax: this.estimatedTax() ?? 0, + }; + }); constructor( private i18nService: I18nService, @@ -186,13 +212,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } }); - this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( - startWith(this.formGroup.controls.billingAddress.value), - debounceTime(1000), - // Only proceed when form has required values - switchMap(() => this.refreshSalesTax$()), - ); - this.loading.set(false); } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 2d653ff200b..307f170f116 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -65,7 +65,7 @@ }} diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 8d99b807540..2fc39218cf8 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { DiscountInfo } from "@bitwarden/pricing"; +import { Discount, DiscountTypes, Maybe } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -251,15 +251,13 @@ export class UserSubscriptionComponent implements OnInit { } } - getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + getDiscount(discount: BillingCustomerDiscount | null): Maybe { if (!discount) { return null; } - return { - active: discount.active, - percentOff: discount.percentOff, - amountOff: discount.amountOff, - }; + return discount.amountOff + ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 57ea900fa69..db30a9d1153 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1188,7 +1188,7 @@ "message": "Me" }, "myItems": { - "message": "My items" + "message": "My Items" }, "myVault": { "message": "My vault" @@ -3275,7 +3275,7 @@ "nextChargeHeader": { "message": "Next Charge" }, - "plan": { + "plan": { "message": "Plan" }, "details": { @@ -3673,9 +3673,6 @@ "defaultCollection": { "message": "Default collection" }, - "myItems": { - "message": "My Items" - }, "getHelp": { "message": "Get help" }, @@ -4500,7 +4497,6 @@ "updateBrowser": { "message": "Update browser" }, - "generatingYourAccessIntelligence": { "message": "Generating your Access Intelligence..." }, @@ -5888,22 +5884,22 @@ "message": "credential lifecycle", "description": "This will be used as a hyperlink" }, - "organizationDataOwnershipWarningTitle":{ + "organizationDataOwnershipWarningTitle": { "message": "Are you sure you want to proceed?" }, - "organizationDataOwnershipWarning1":{ + "organizationDataOwnershipWarning1": { "message": "will remain accessible to members" }, - "organizationDataOwnershipWarning2":{ + "organizationDataOwnershipWarning2": { "message": "will not be automatically selected when creating new items" }, - "organizationDataOwnershipWarning3":{ + "organizationDataOwnershipWarning3": { "message": "cannot be managed from the Admin Console until the user is offboarded" }, - "organizationDataOwnershipWarningContentTop":{ + "organizationDataOwnershipWarningContentTop": { "message": "By turning this policy off, the default collection: " }, - "organizationDataOwnershipWarningContentBottom":{ + "organizationDataOwnershipWarningContentBottom": { "message": "Learn more about the ", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'" }, @@ -6027,7 +6023,7 @@ "uriMatchDetectionOptionsLabel": { "message": "Default URI match detection" }, - "invalidUriMatchDefaultPolicySetting": { + "invalidUriMatchDefaultPolicySetting": { "message": "Please select a valid URI match detection option.", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, @@ -8937,7 +8933,7 @@ } }, "accessedSecret": { - "message": "Accessed secret $SECRET_ID$.", + "message": "Accessed secret $SECRET_ID$.", "placeholders": { "secret_id": { "content": "$1", @@ -8945,7 +8941,7 @@ } } }, - "editedSecretWithId": { + "editedSecretWithId": { "message": "Edited a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8954,7 +8950,7 @@ } } }, - "deletedSecretWithId": { + "deletedSecretWithId": { "message": "Deleted a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8972,7 +8968,7 @@ } } }, - "restoredSecretWithId": { + "restoredSecretWithId": { "message": "Restored a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8981,7 +8977,7 @@ } } }, - "createdSecretWithId": { + "createdSecretWithId": { "message": "Created a new secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8991,7 +8987,7 @@ } }, "accessedProjectWithIdentifier": { - "message": "Accessed a project with identifier: $PROJECT_ID$.", + "message": "Accessed a project with identifier: $PROJECT_ID$.", "placeholders": { "project_id": { "content": "$1", @@ -9000,7 +8996,7 @@ } }, "nameUnavailableProjectDeleted": { - "message": "Deleted project Id: $PROJECT_ID$", + "message": "Deleted project Id: $PROJECT_ID$", "placeholders": { "project_id": { "content": "$1", @@ -9009,7 +9005,7 @@ } }, "nameUnavailableSecretDeleted": { - "message": "Deleted secret Id: $SECRET_ID$", + "message": "Deleted secret Id: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -9018,7 +9014,7 @@ } }, "nameUnavailableServiceAccountDeleted": { - "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", + "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -9026,7 +9022,7 @@ } } }, - "editedProjectWithId": { + "editedProjectWithId": { "message": "Edited a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9105,7 +9101,7 @@ } } }, - "deletedProjectWithId": { + "deletedProjectWithId": { "message": "Deleted a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9114,7 +9110,7 @@ } } }, - "createdProjectWithId": { + "createdProjectWithId": { "message": "Created a new project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9832,15 +9828,15 @@ "message": "Common formats", "description": "Label indicating the most common import formats" }, - "uriMatchDefaultStrategyHint": { + "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -9848,11 +9844,11 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, - "warningCapitalized": { + "warningCapitalized": { "message": "Warning", "description": "Warning (should maintain locale-relevant capitalization)" }, @@ -12193,9 +12189,6 @@ "updateYourEncryptionSettings": { "message": "Update your encryption settings" }, - "updateSettings": { - "message": "Update settings" - }, "algorithm": { "message": "Algorithm" }, @@ -12266,7 +12259,7 @@ } } }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -12284,10 +12277,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -12421,7 +12414,7 @@ } } }, - "howToManageMyVault": { + "howToManageMyVault": { "message": "How do I manage my vault?" }, "transferItemsToOrganizationTitle": { @@ -12451,7 +12444,7 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, - "youHaveBitwardenPremium": { + "youHaveBitwardenPremium": { "message": "You have Bitwarden Premium" }, "viewAndManagePremiumSubscription": { @@ -12469,7 +12462,7 @@ } } }, - "uploadLicenseFile": { + "uploadLicenseFile": { "message": "Upload license file" }, "uploadYourLicenseFile": { @@ -12487,7 +12480,7 @@ } } }, - "alreadyHaveSubscriptionQuestion": { + "alreadyHaveSubscriptionQuestion": { "message": "Already have a subscription?" }, "alreadyHaveSubscriptionSelfHostedMessage": { @@ -12496,7 +12489,81 @@ "viewAllPlans": { "message": "View all plans" }, - "planDescPremium":{ + "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index 85695ea1395..e2fe7d80dc0 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -1,21 +1,23 @@ -@let passwordManager = this.passwordManager(); -@let additionalStorage = this.additionalStorage(); -@let secretsManager = this.secretsManager(); -@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts; +@let cart = this.cart(); +@let term = this.term();

-

- {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD -

-   - / {{ passwordManager.cadence | i18n }} + @if (this.header(); as header) { + + } @else { +

+ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD +

+   + / {{ term }} + }
+ +
+ diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx new file mode 100644 index 00000000000..4519d19a530 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -0,0 +1,159 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as AdditionalOptionsCardStories from "./additional-options-card.component.stories"; + + + +# Additional Options Card + +A UI component for displaying additional subscription management options with action buttons for +downloading license and canceling subscription. The component provides quick access to important +subscription actions. + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Design](#design) +- [Examples](#examples) + - [Default](#default) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The additional options card component displays important subscription management actions on billing +pages and subscription dashboards. It provides quick access to download license and cancel +subscription actions. + +```ts +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ----------------------- | --------- | ---------------------------------------------------------------------- | +| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ----------------------------- | ------------------------------------------- | +| `callToActionClicked` | `AdditionalOptionsCardAction` | Emitted when a user clicks an action button | + +**AdditionalOptionsCardAction Type:** + +```typescript +type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +``` + +## Design + +The component follows the Bitwarden design system with: + +- **Simple Card Layout**: Clean card design with title and description +- **Action Buttons**: Two prominent buttons for key subscription actions +- **Modern Angular**: Standalone component with signal-based outputs +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Button Variants**: Secondary button for download, danger button for cancel +- **Internationalization**: All text uses i18n service for translation support + +## Examples + +### Default + +Standard display with download license and cancel subscription buttons: + + + +```html + + +``` + +**Handler example:** + +```typescript +handleAction(action: AdditionalOptionsCardAction) { + switch (action) { + case "download-license": + // Handle license download + break; + case "cancel-subscription": + // Handle subscription cancellation + break; + } +} +``` + +### Actions Disabled + +Component with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +downloading the license or processing subscription cancellation. + +## Features + +- **Download License**: Provides quick access to download subscription license +- **Cancel Subscription**: Provides quick access to cancel subscription with danger styling +- **Event Emission**: Emits typed events for handling user actions +- **Internationalization**: All text uses i18n service for translation support +- **Type Safety**: Strong TypeScript typing for action events +- **Accessible**: Proper button semantics and keyboard navigation + +## Do's and Don'ts + +### ✅ Do + +- Handle both `download-license` and `cancel-subscription` events in parent components +- Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) +- Disable buttons or show loading states during async operations +- Provide clear user feedback after action completion +- Consider adding additional safety measures for subscription cancellation + +### ❌ Don't + +- Ignore the `callToActionClicked` events - they require handling +- Execute subscription cancellation without user confirmation +- Display this component to users who don't have permission to perform these actions +- Allow multiple simultaneous action executions +- Forget to handle error cases when actions fail + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Button Variants**: Clear visual distinction between secondary and danger actions +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive button text for all actions +- **Color Differentiation**: Danger button uses red color to indicate destructive action +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts new file mode 100644 index 00000000000..345de037fd3 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; + +describe("AdditionalOptionsCardComponent", () => { + let component: AdditionalOptionsCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [AdditionalOptionsCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(AdditionalOptionsCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("rendering", () => { + it("should display the title", () => { + const title = fixture.debugElement.query(By.css("h3")); + expect(title.nativeElement.textContent.trim()).toBe("Additional options"); + }); + + it("should display the description", () => { + const description = fixture.debugElement.query(By.css("p")); + expect(description.nativeElement.textContent.trim()).toContain( + "For additional help in managing your subscription", + ); + }); + + it("should render both action buttons", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should render download license button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Download license"); + }); + + it("should render cancel subscription button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Cancel subscription"); + }); + }); + + describe("callsToActionDisabled", () => { + it("should disable both buttons when callsToActionDisabled is true", () => { + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable both buttons when callsToActionDisabled is false", () => { + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + }); + + describe("button click events", () => { + it("should emit download-license action when download button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("download-license"); + }); + + it("should emit cancel-subscription action when cancel button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("cancel-subscription"); + }); + }); +}); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts new file mode 100644 index 00000000000..66c151f536f --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -0,0 +1,49 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AdditionalOptionsCardComponent } from "./additional-options-card.component"; + +export default { + title: "Billing/Additional Options Card", + component: AdditionalOptionsCardComponent, + description: + "Displays additional subscription management options with action buttons for downloading license and canceling subscription.", + decorators: [ + moduleMetadata({ + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts new file mode 100644 index 00000000000..a962a167ec6 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -0,0 +1,17 @@ +import { Component, ChangeDetectionStrategy, output, input } from "@angular/core"; + +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; + +@Component({ + selector: "billing-additional-options-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./additional-options-card.component.html", + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], +}) +export class AdditionalOptionsCardComponent { + readonly callsToActionDisabled = input(false); + readonly callToActionClicked = output(); +} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html new file mode 100644 index 00000000000..c11f1917176 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -0,0 +1,39 @@ + + +

+

{{ title() }}

+

{{ description() }}

+
+ + +
+ +
+ + +
+ + +
+ diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx new file mode 100644 index 00000000000..43215cb863c --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -0,0 +1,333 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as StorageCardStories from "./storage-card.component.stories"; + + + +# Storage Card + +A visual component for displaying encrypted file storage usage with a progress bar and action +buttons. The component dynamically adapts its appearance based on storage capacity (empty, used, or +full). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Storage States](#storage-states) +- [Design](#design) +- [Examples](#examples) + - [Empty](#empty) + - [Used](#used) + - [Full](#full) + - [Low Usage (10%)](#low-usage-10) + - [Medium Usage (75%)](#medium-usage-75) + - [Nearly Full (95%)](#nearly-full-95) + - [Large Storage Pool (1TB)](#large-storage-pool-1tb) + - [Small Storage Pool (1GB)](#small-storage-pool-1gb) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The storage card component displays storage usage information on billing pages, account management +interfaces, and subscription dashboards. It provides visual feedback through a progress bar and +action buttons for managing storage. + +```ts +import { StorageCardComponent, Storage } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ----------------------- | --------- | ---------------------------------------------------------------------- | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ------------------- | ------------------------------------------------------- | +| `callToActionClicked` | `StorageCardAction` | Emitted when a user clicks add or remove storage button | + +**StorageCardAction Type:** + +```typescript +type StorageCardAction = "add-storage" | "remove-storage"; +``` + +## Data Structure + +The component uses the `Storage` type: + +```typescript +type Storage = { + available: number; // Total GB available + used: number; // GB used + readableUsed: string; // Formatted string (e.g., "2.5 GB") +}; +``` + +## Storage States + +The component automatically adapts its appearance based on storage usage: + +- **Empty**: 0% used - Gray progress bar, "Storage" title, empty description +- **Used**: 1-99% used - Blue progress bar, "Storage" title, used description +- **Full**: 100% used - Red progress bar, "Storage full" title, full description with warning + +Key behaviors: + +- Progress bar color changes from blue (primary) to red (danger) when full +- Remove storage button is disabled when storage is full +- Title changes to "Storage full" when at capacity +- Description provides context-specific messaging + +## Design + +The component follows the Bitwarden design system with: + +- **Visual Progress Bar**: Animated bar showing storage usage percentage +- **Responsive Colors**: Blue for normal usage, red for full capacity +- **Action Buttons**: Secondary button style for add/remove actions +- **Modern Angular**: Uses signal inputs (`input.required`) and `computed` signals +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Card Layout**: Wrapped in `bit-card` component with consistent spacing + +## Examples + +### Empty + +Storage with no files uploaded: + + + +```html + + +``` + +### Used + +Storage with partial usage (50%): + + + +```html + + +``` + +### Full + +Storage at full capacity with disabled remove button: + + + +```html + + +``` + +**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns +red. + +### Low Usage (10%) + +Minimal storage usage: + + + +```html + + +``` + +### Medium Usage (75%) + +Substantial storage usage: + + + +```html + + +``` + +### Nearly Full (95%) + +Storage approaching capacity: + + + +```html + + +``` + +### Large Storage Pool (1TB) + +Enterprise-level storage allocation: + + + +```html + + +``` + +### Small Storage Pool (1GB) + +Minimal storage allocation: + + + +```html + + +``` + +### Actions Disabled + +Storage card with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +adding or removing storage. + +## Features + +- **Visual Progress Bar**: Animated progress indicator showing storage usage percentage +- **Dynamic Colors**: Blue (primary) for normal usage, red (danger) when full +- **Context-Aware Titles**: Changes from "Storage" to "Storage full" at capacity +- **Descriptive Messages**: Clear descriptions of current storage status +- **Action Buttons**: Add and remove storage with appropriate enabled/disabled states +- **Automatic Calculations**: Percentage computed from available and used values +- **Responsive Design**: Adapts to container width with flexible layout +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Strong TypeScript typing for storage data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle both `add-storage` and `remove-storage` events in parent components +- Provide accurate storage data with `available`, `used`, and `readableUsed` fields +- Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` +- Keep `used` value less than or equal to `available` under normal circumstances +- Update storage data in real-time when user adds or removes storage +- Disable UI interactions when storage operations are in progress +- Show loading states during async storage operations + +### ❌ Don't + +- Omit the `readableUsed` field - it's required for display +- Use inconsistent units between `available` and `used` (both should be in GB) +- Allow negative values for storage amounts +- Ignore the `callToActionClicked` events - they require handling +- Display inaccurate or stale storage information +- Override progress bar colors without considering accessibility +- Show progress percentages greater than 100% +- Use this component for non-storage related progress indicators + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Disabled State**: Visual and functional disabled state for remove button when full +- **Color Contrast**: Sufficient contrast ratios for text and progress bar colors +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive text for all storage states and actions +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes +- **Visual Feedback**: Multiple indicators of state (color, text, disabled buttons) diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts new file mode 100644 index 00000000000..ae0d7ad9dcb --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -0,0 +1,285 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; + +describe("StorageCardComponent", () => { + let component: StorageCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + const baseStorage: Storage = { + available: 5, + used: 0, + readableUsed: "0 GB", + }; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [StorageCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(StorageCardComponent); + component = fixture.componentInstance; + }); + + function setupComponent(storage: Storage) { + fixture.componentRef.setInput("storage", storage); + fixture.detectChanges(); + } + + it("should create", () => { + setupComponent(baseStorage); + expect(component).toBeTruthy(); + }); + + describe("isEmpty", () => { + it("should return true when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isEmpty()).toBe(true); + }); + + it("should return false when storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + + it("should return false when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + }); + + describe("isFull", () => { + it("should return false when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isFull()).toBe(false); + }); + + it("should return false when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isFull()).toBe(false); + }); + + it("should return true when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isFull()).toBe(true); + }); + + it("should return true when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.isFull()).toBe(true); + }); + }); + + describe("percentageUsed", () => { + it("should return 0 when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.percentageUsed()).toBe(0); + }); + + it("should return 50 when half of storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should return 100 when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should cap at 100 when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should return 0 when available is 0", () => { + setupComponent({ available: 0, used: 0, readableUsed: "0 GB" }); + expect(component.percentageUsed()).toBe(0); + }); + }); + + describe("title", () => { + it("should display 'Storage' when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage' when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage full' when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.title()).toBe("Storage full"); + }); + }); + + describe("description", () => { + it("should display used description when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.description()).toContain("You have used 0 GB out of 5 GB"); + }); + + it("should display used description when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.description()).toContain("You have used 2.5 GB out of 5 GB"); + }); + + it("should display full description when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + const desc = component.description(); + expect(desc).toContain("You have used all 5 GB"); + expect(desc).toContain("To continue storing files, add more storage"); + }); + }); + + describe("progressBarColor", () => { + it("should return primary color when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should return danger color when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); + + describe("canRemoveStorage", () => { + it("should return true when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.canRemoveStorage()).toBe(true); + }); + + it("should return false when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.canRemoveStorage()).toBe(false); + }); + }); + + describe("button rendering", () => { + it("should render both buttons", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should enable remove button when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1].nativeElement; + expect(removeButton.disabled).toBe(false); + }); + + it("should disable remove button when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1]; + expect(removeButton.attributes["aria-disabled"]).toBe("true"); + }); + }); + + describe("callsToActionDisabled", () => { + it("should disable both buttons when callsToActionDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + }); + + describe("button click events", () => { + it("should emit add-storage action when add button is clicked", () => { + setupComponent(baseStorage); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("add-storage"); + }); + + it("should emit remove-storage action when remove button is clicked", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("remove-storage"); + }); + }); + + describe("progress bar rendering", () => { + it("should render bit-progress component when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + const progressBar = fixture.debugElement.query(By.css("bit-progress")); + expect(progressBar).toBeTruthy(); + }); + + it("should pass correct barWidth to bit-progress when half storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should pass correct barWidth to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should pass primary color to bit-progress when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should pass danger color to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); +}); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts new file mode 100644 index 00000000000..8c2070e59f9 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -0,0 +1,148 @@ +import { CommonModule } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export default { + title: "Billing/Storage Card", + component: StorageCardComponent, + description: + "Displays storage usage with a visual progress bar and action buttons for adding or removing storage.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, + I18nPipe, + ], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + storage: { + available: 5, + used: 0, + readableUsed: "0 GB", + } satisfies Storage, + }, +}; + +export const Used: Story = { + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + }, +}; + +export const Full: Story = { + args: { + storage: { + available: 5, + used: 5, + readableUsed: "5 GB", + } satisfies Storage, + }, +}; + +export const LowUsage: Story = { + name: "Low Usage (10%)", + args: { + storage: { + available: 5, + used: 0.5, + readableUsed: "500 MB", + } satisfies Storage, + }, +}; + +export const MediumUsage: Story = { + name: "Medium Usage (75%)", + args: { + storage: { + available: 5, + used: 3.75, + readableUsed: "3.75 GB", + } satisfies Storage, + }, +}; + +export const NearlyFull: Story = { + name: "Nearly Full (95%)", + args: { + storage: { + available: 5, + used: 4.75, + readableUsed: "4.75 GB", + } satisfies Storage, + }, +}; + +export const LargeStorage: Story = { + name: "Large Storage Pool (1TB)", + args: { + storage: { + available: 1000, + used: 734, + readableUsed: "734 GB", + } satisfies Storage, + }, +}; + +export const SmallStorage: Story = { + name: "Small Storage Pool (1GB)", + args: { + storage: { + available: 1, + used: 0.8, + readableUsed: "800 MB", + } satisfies Storage, + }, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts new file mode 100644 index 00000000000..988f4a0ec60 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -0,0 +1,68 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { Storage } from "../../types/storage"; + +export type StorageCardAction = "add-storage" | "remove-storage"; + +@Component({ + selector: "billing-storage-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./storage-card.component.html", + imports: [CommonModule, ButtonModule, CardComponent, ProgressModule, TypographyModule, I18nPipe], +}) +export class StorageCardComponent { + private i18nService = inject(I18nService); + + readonly storage = input.required(); + + readonly callsToActionDisabled = input(false); + + readonly callToActionClicked = output(); + + readonly isEmpty = computed(() => this.storage().used === 0); + + readonly isFull = computed(() => { + const storage = this.storage(); + return storage.used >= storage.available; + }); + + readonly percentageUsed = computed(() => { + const storage = this.storage(); + if (storage.available === 0) { + return 0; + } + return Math.min((storage.used / storage.available) * 100, 100); + }); + + readonly title = computed(() => { + return this.isFull() ? this.i18nService.t("storageFull") : this.i18nService.t("storage"); + }); + + readonly description = computed(() => { + const storage = this.storage(); + const available = storage.available; + const readableUsed = storage.readableUsed; + + if (this.isFull()) { + return this.i18nService.t("storageFullDescription", available.toString()); + } + + return this.i18nService.t("storageUsedDescription", readableUsed, available.toString()); + }); + + readonly progressBarColor = computed<"danger" | "primary">(() => { + return this.isFull() ? "danger" : "primary"; + }); + + readonly canRemoveStorage = computed(() => !this.isFull()); +} diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.html b/libs/subscription/src/components/subscription-card/subscription-card.component.html new file mode 100644 index 00000000000..524adc8d008 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.html @@ -0,0 +1,94 @@ + + +

+

{{ title() }}

+ + + + {{ badge().text }} + +
+ + +
+ +
+ + + @if (callout(); as callout) { + +
+

{{ callout.description }}

+ @if (callout.callsToAction) { +
+ @for (cta of callout.callsToAction; track cta.action) { + + } +
+ } +
+
+ } + + + +

+ @let status = subscription().status; + @switch (status) { + @case ("incomplete") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("incomplete_expired") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("trialing") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("active") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("past_due") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("canceled") { + {{ "yourSubscriptionWasCanceledOn" | i18n }} + {{ canceled() | date: dateFormat }} + } + @case ("unpaid") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + } +

+
diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx new file mode 100644 index 00000000000..0f605f0f05e --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -0,0 +1,459 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as SubscriptionCardStories from "./subscription-card.component.stories"; + + + +# Subscription Card + +A comprehensive UI component for displaying subscription status, payment details, and contextual +action prompts based on subscription state. Dynamically adapts its presentation based on the +subscription status (active, trialing, incomplete, past due, canceled, unpaid, etc.). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Subscription States](#subscription-states) +- [Design](#design) +- [Examples](#examples) + - [Active](#active) + - [Active With Upgrade](#active-with-upgrade) + - [Trial](#trial) + - [Trial With Upgrade](#trial-with-upgrade) + - [Incomplete Payment](#incomplete-payment) + - [Incomplete Expired](#incomplete-expired) + - [Past Due](#past-due) + - [Pending Cancellation](#pending-cancellation) + - [Unpaid](#unpaid) + - [Canceled](#canceled) + - [Enterprise](#enterprise) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The subscription card component is designed to display comprehensive subscription information on +billing pages, account management interfaces, and subscription dashboards. + +```ts +import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ------------------- | ----------------------- | ----------------------------------------------------------------------- | +| `title` | `string` | **Required.** The title displayed at the top of the card | +| `subscription` | `BitwardenSubscription` | **Required.** The subscription data including status, cart, and storage | +| `showUpgradeButton` | `boolean` | **Optional.** Whether to show the upgrade callout (default: `false`) | + +### Outputs + +| Output | Type | Description | +| --------------------- | ---------------- | ---------------------------------------------------------- | +| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | + +**PlanCardAction Type:** + +```typescript +type PlanCardAction = + | "contact-support" + | "manage-invoices" + | "reinstate-subscription" + | "update-payment" + | "upgrade-plan"; +``` + +## Data Structure + +The component uses the `BitwardenSubscription` type, which is a discriminated union based on status: + +```typescript +type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); + +type HasCart = { + cart: Cart; // From @bitwarden/pricing +}; + +type HasStorage = { + storage: { + available: number; + readableUsed: string; + used: number; + }; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; +``` + +## Subscription States + +The component dynamically adapts its appearance and calls-to-action based on the subscription +status: + +- **active**: Subscription is active and paid up +- **trialing**: Subscription is in trial period +- **incomplete**: Payment failed, requires action +- **incomplete_expired**: Payment issue expired, subscription suspended +- **past_due**: Payment overdue but within grace period +- **unpaid**: Subscription suspended due to non-payment +- **canceled**: Subscription was canceled + +Each state displays an appropriate badge, callout message, and relevant action buttons. + +## Design + +The component follows the Bitwarden design system with: + +- **Status Badge**: Color-coded badges (success, warning, danger) indicating subscription state +- **Cart Summary**: Integrated cart summary showing pricing details +- **Contextual Callouts**: Warning/info/danger callouts with appropriate actions +- **Modern Angular**: Uses signal inputs (`input.required`, `input`) and `computed` signals +- **OnPush Change Detection**: Optimized performance with change detection strategy +- **Typography**: Consistent text styling using the typography module +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Responsive Layout**: Flexbox-based layout that adapts to container size + +## Examples + +### Active + +Standard active subscription with regular billing: + + + +```html + + +``` + +### Active With Upgrade + +Active subscription with upgrade promotion callout: + + + +```html + + +``` + +### Trial + +Subscription in trial period showing next charge date: + + + +```html + + +``` + +### Trial With Upgrade + +Trial subscription with upgrade option displayed: + + + +```html + + +``` + +### Incomplete Payment + +Payment failed, showing warning with update payment action: + + + +```html + + +``` + +**Actions available:** Update Payment, Contact Support + +### Incomplete Expired + +Payment issue expired, subscription has been suspended: + + + +```html + + +``` + +**Actions available:** Contact Support + +### Past Due + +Payment past due with active grace period: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Pending Cancellation + +Active subscription scheduled to be canceled: + + + +```html + + +``` + +**Actions available:** Reinstate Subscription + +### Unpaid + +Subscription suspended due to unpaid invoices: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Canceled + +Subscription that has been canceled: + + + +```html + + +``` + +**Note:** Canceled subscriptions display no callout or actions. + +### Enterprise + +Enterprise subscription with multiple products and discount: + + + +```html + + +``` + +## Features + +- **Dynamic Badge**: Status badge changes color and text based on subscription state +- **Contextual Callouts**: Warning, info, or danger callouts with relevant messages +- **Action Buttons**: Context-specific call-to-action buttons (update payment, contact support, + etc.) +- **Cart Summary Integration**: Embedded cart summary with pricing breakdown +- **Custom Header Support**: Cart summary can display custom headers based on subscription status +- **Date Formatting**: Consistent date formatting throughout (MMM. d, y format) +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Discriminated union types ensure type-safe subscription data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Emits typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle all `callToActionClicked` events appropriately in parent components +- Provide complete `BitwardenSubscription` objects with all required fields +- Use the correct subscription status from the defined status types +- Include accurate date information for nextCharge, suspension, and cancelAt fields +- Set `showUpgradeButton` to `true` only when upgrade paths are available +- Use real translation keys that exist in the i18n messages file +- Provide accurate storage information with readable format strings + +### ❌ Don't + +- Omit required fields from the BitwardenSubscription type +- Use custom status strings not defined in the type +- Display upgrade buttons for users who cannot upgrade +- Ignore the `callToActionClicked` events - they require handling +- Mix subscription states (e.g., having both `canceled` date and `nextCharge`) +- Provide incorrect dates that don't match the subscription status +- Override component styles without ensuring accessibility +- Use placeholder or mock data in production environments + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

`, `

`, `

` tags +- **ARIA Labels**: Badge variants use appropriate semantic colors +- **Keyboard Navigation**: All action buttons are keyboard accessible +- **Focus Management**: Clear focus indicators on interactive elements +- **Color Contrast**: Sufficient contrast ratios for all text and badge variants +- **Screen Reader Support**: Descriptive text for all interactive elements +- **Button Types**: Proper `type="button"` attributes on all buttons +- **Date Formatting**: Human-readable date formats for assistive technologies diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts new file mode 100644 index 00000000000..3485f2a493a --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -0,0 +1,704 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Cart } from "@bitwarden/pricing"; +import { BitwardenSubscription, SubscriptionCardComponent } from "@bitwarden/subscription"; + +describe("SubscriptionCardComponent", () => { + let component: SubscriptionCardComponent; + let fixture: ComponentFixture; + + const mockCart: Cart = { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 50, + }, + }, + cadence: "monthly", + estimatedTax: 0, + }; + + const baseSubscription = { + cart: mockCart, + storage: { + available: 1000, + readableUsed: "100 MB", + used: 100, + }, + }; + + const mockI18nService = { + t: (key: string, ...params: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: "We could not process your payment", + contactSupportShort: "Contact support", + yourSubscriptionHasExpired: "Your subscription has expired", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: "Premium share even more", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${params[0]} days ending ${params[1]}`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: "To reactivate your subscription", + }; + return translations[key] || key; + }, + }; + + function setupComponent(subscription: BitwardenSubscription, title = "Test Plan") { + fixture.componentRef.setInput("title", title); + fixture.componentRef.setInput("subscription", subscription); + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SubscriptionCardComponent], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: mockI18nService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubscriptionCardComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component).toBeTruthy(); + }); + + describe("Badge rendering", () => { + it("should display 'Update payment' badge with warning variant for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Update payment"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge).toBeTruthy(); + expect(badge.nativeElement.textContent.trim()).toBe("Update payment"); + }); + + it("should display 'Expired' badge with danger variant for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Expired"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Expired"); + }); + + it("should display 'Trial' badge with success variant for trialing status", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Trial"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Trial"); + }); + + it("should display 'Pending cancellation' badge for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Active' badge with success variant for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Active"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Active"); + }); + + it("should display 'Pending cancellation' badge for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Past due' badge with warning variant for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Past due"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Past due"); + }); + + it("should display 'Canceled' badge with danger variant for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.badge().text).toBe("Canceled"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Canceled"); + }); + + it("should display 'Unpaid' badge with danger variant for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Unpaid"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Unpaid"); + }); + }); + + describe("Callout rendering", () => { + it("should display incomplete callout with update payment and contact support actions", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Update payment"); + expect(calloutData!.description).toContain("We could not process your payment"); + expect(calloutData!.callsToAction?.length).toBe(2); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("We could not process your payment"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Update payment"); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display incomplete_expired callout with contact support action", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Expired"); + expect(calloutData!.description).toContain("Your subscription has expired"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Your subscription has expired"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display pending cancellation callout for active status with cancelAt", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Pending cancellation"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Reinstate subscription"); + }); + + it("should display upgrade callout for active status when showUpgradeButton is true", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("info"); + expect(calloutData!.title).toBe("Upgrade your plan"); + expect(calloutData!.description).toContain("Premium share even more"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Premium share even more"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Upgrade now"); + }); + + it("should not display upgrade callout when showUpgradeButton is false", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", false); + fixture.detectChanges(); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display past_due callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Past due"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + + it("should not display callout for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display unpaid callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Unpaid"); + expect(calloutData!.description).toContain("To reactivate your subscription"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("To reactivate your subscription"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + }); + + describe("Call-to-action clicks", () => { + it("should emit update-payment action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + expect(buttons.length).toBe(2); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("update-payment"); + }); + + it("should emit contact-support action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("contact-support"); + }); + + it("should emit reinstate-subscription action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("reinstate-subscription"); + }); + + it("should emit upgrade-plan action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("upgrade-plan"); + }); + + it("should emit manage-invoices action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("manage-invoices"); + }); + }); + + describe("Cart summary header content", () => { + it("should display suspension date for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for trialing status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display canceled date for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + }); + + describe("Title rendering", () => { + it("should display the provided title", () => { + setupComponent( + { + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }, + "Premium Plan", + ); + + const title = fixture.debugElement.query(By.css("h2[bitTypography='h3']")); + expect(title.nativeElement.textContent.trim()).toBe("Premium Plan"); + }); + }); + + describe("Computed properties", () => { + it("should compute cancelAt for active status with cancelAt date", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + expect(component.cancelAt()).toEqual(cancelDate); + }); + + it("should compute cancelAt as undefined for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.cancelAt()).toBeUndefined(); + }); + + it("should compute canceled date for canceled status", () => { + const canceledDate = new Date("2025-01-15"); + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: canceledDate, + }); + + expect(component.canceled()).toEqual(canceledDate); + }); + + it("should compute canceled as undefined for non-canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.canceled()).toBeUndefined(); + }); + + it("should compute nextCharge for active status", () => { + const nextChargeDate = new Date("2025-02-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: nextChargeDate, + }); + + expect(component.nextCharge()).toEqual(nextChargeDate); + }); + + it("should compute nextCharge as undefined for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.nextCharge()).toBeUndefined(); + }); + + it("should compute suspension date for incomplete status", () => { + const suspensionDate = new Date("2025-02-15"); + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: suspensionDate, + gracePeriod: 7, + }); + + expect(component.suspension()).toEqual(suspensionDate); + }); + + it("should compute suspension as undefined for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.suspension()).toBeUndefined(); + }); + }); +}); diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts new file mode 100644 index 00000000000..abe5789382b --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -0,0 +1,411 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, +} from "@bitwarden/components"; +import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { BitwardenSubscription } from "../../types/bitwarden-subscription"; + +import { SubscriptionCardComponent } from "./subscription-card.component"; + +export default { + title: "Billing/Subscription Card", + component: SubscriptionCardComponent, + description: + "Displays subscription status, payment details, and action prompts based on subscription state.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: + "We could not process your payment. Please update your payment method or contact the support team for assistance.", + contactSupportShort: "Contact Support", + yourSubscriptionHasExpired: + "Your subscription has expired. Please contact the support team for assistance.", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: + "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${args[0]} days from your subscription expiration date. Please resolve the past due invoices by ${args[1]}.`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: + "To reactivate your subscription, please resolve the past due invoices.", + yourSubscriptionWillBeSuspendedOn: "Your subscription will be suspended on", + yourSubscriptionWasSuspendedOn: "Your subscription was suspended on", + yourSubscriptionWillBeCanceledOn: "Your subscription will be canceled on", + yourNextChargeIsFor: "Your next charge is for", + dueOn: "due on", + yourSubscriptionWasCanceledOn: "Your subscription was canceled on", + members: "Members", + additionalStorageGB: "Additional storage GB", + month: "month", + year: "year", + estimatedTax: "Estimated tax", + total: "Total", + expandPurchaseDetails: "Expand purchase details", + collapsePurchaseDetails: "Collapse purchase details", + passwordManager: "Password Manager", + secretsManager: "Secrets Manager", + additionalStorageGb: "Additional storage (GB)", + additionalServiceAccountsV2: "Additional machine accounts", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Active: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const ActiveWithUpgrade: Story = { + name: "Active - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Trial: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const TrialWithUpgrade: Story = { + name: "Trial - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Incomplete: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const IncompleteExpired: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete_expired", + suspension: new Date("2025-01-01"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PastDue: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "past_due", + suspension: new Date("2025-02-05"), + gracePeriod: 14, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PendingCancellation: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cancelAt: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Unpaid: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "unpaid", + suspension: new Date("2025-01-20"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Canceled: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "canceled", + canceled: new Date("2025-01-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Enterprise: Story = { + args: { + title: "Enterprise Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 7, + }, + additionalStorage: { + quantity: 2, + name: "additionalStorageGB", + cost: 0.5, + }, + }, + secretsManager: { + seats: { + quantity: 3, + name: "members", + cost: 13, + }, + additionalServiceAccounts: { + quantity: 5, + name: "additionalServiceAccountsV2", + cost: 1, + }, + }, + discount: { + type: DiscountTypes.PercentOff, + active: true, + value: 0.25, + }, + cadence: "monthly", + estimatedTax: 6.4, + }, + storage: { + available: 7, + readableUsed: "7 GB", + used: 0, + }, + }, + }, +}; diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts new file mode 100644 index 00000000000..f52127a0104 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -0,0 +1,274 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + BadgeVariant, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, + CalloutTypes, + ButtonType, +} from "@bitwarden/components"; +import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; +import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export type PlanCardAction = + | "contact-support" + | "manage-invoices" + | "reinstate-subscription" + | "update-payment" + | "upgrade-plan"; + +type Badge = { text: string; variant: BadgeVariant }; + +type Callout = Maybe<{ + title: string; + type: CalloutTypes; + icon?: string; + description: string; + callsToAction?: { + text: string; + buttonType: ButtonType; + action: PlanCardAction; + }[]; +}>; + +@Component({ + selector: "billing-subscription-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./subscription-card.component.html", + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], +}) +export class SubscriptionCardComponent { + private datePipe = inject(DatePipe); + private i18nService = inject(I18nService); + + protected readonly dateFormat = "MMM. d, y"; + + readonly title = input.required(); + + readonly subscription = input.required(); + + readonly showUpgradeButton = input(false); + + readonly callToActionClicked = output(); + + readonly badge = computed(() => { + const subscription = this.subscription(); + const pendingCancellation: Badge = { + text: this.i18nService.t("pendingCancellation"), + variant: "warning", + }; + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + text: this.i18nService.t("updatePayment"), + variant: "warning", + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + text: this.i18nService.t("expired"), + variant: "danger", + }; + } + case SubscriptionStatuses.Trialing: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("trial"), + variant: "success", + }; + } + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("active"), + variant: "success", + }; + } + case SubscriptionStatuses.PastDue: { + return { + text: this.i18nService.t("pastDue"), + variant: "warning", + }; + } + case SubscriptionStatuses.Canceled: { + return { + text: this.i18nService.t("canceled"), + variant: "danger", + }; + } + case SubscriptionStatuses.Unpaid: { + return { + text: this.i18nService.t("unpaid"), + variant: "danger", + }; + } + } + }); + + readonly callout = computed(() => { + const subscription = this.subscription(); + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + title: this.i18nService.t("updatePayment"), + type: "warning", + description: this.i18nService.t("weCouldNotProcessYourPayment"), + callsToAction: [ + { + text: this.i18nService.t("updatePayment"), + buttonType: "unstyled", + action: "update-payment", + }, + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + title: this.i18nService.t("expired"), + type: "danger", + description: this.i18nService.t("yourSubscriptionHasExpired"), + callsToAction: [ + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + const cancelAt = this.datePipe.transform(subscription.cancelAt, this.dateFormat); + return { + title: this.i18nService.t("pendingCancellation"), + type: "warning", + description: this.i18nService.t("yourSubscriptionIsScheduledToCancel", cancelAt!), + callsToAction: [ + { + text: this.i18nService.t("reinstateSubscription"), + buttonType: "unstyled", + action: "reinstate-subscription", + }, + ], + }; + } + if (!this.showUpgradeButton()) { + return null; + } + return { + title: this.i18nService.t("upgradeYourPlan"), + type: "info", + icon: "bwi-gem", + description: this.i18nService.t("premiumShareEvenMore"), + callsToAction: [ + { + text: this.i18nService.t("upgradeNow"), + buttonType: "unstyled", + action: "upgrade-plan", + }, + ], + }; + } + case SubscriptionStatuses.PastDue: { + const suspension = this.datePipe.transform(subscription.suspension, this.dateFormat); + return { + title: this.i18nService.t("pastDue"), + type: "warning", + description: this.i18nService.t( + "youHaveAGracePeriod", + subscription.gracePeriod, + suspension!, + ), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + case SubscriptionStatuses.Canceled: { + return null; + } + case SubscriptionStatuses.Unpaid: { + return { + title: this.i18nService.t("unpaid"), + type: "danger", + description: this.i18nService.t("toReactivateYourSubscription"), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + } + }); + + readonly cancelAt = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.cancelAt; + } + }); + + readonly canceled = computed>(() => { + const subscription = this.subscription(); + if (subscription.status === SubscriptionStatuses.Canceled) { + return subscription.canceled; + } + }); + + readonly nextCharge = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.nextCharge; + } + }); + + readonly suspension = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Incomplete || + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.PastDue || + subscription.status === SubscriptionStatuses.Unpaid + ) { + return subscription.suspension; + } + }); +} diff --git a/libs/subscription/src/index.ts b/libs/subscription/src/index.ts index 3deb7c89d41..29b96017cda 100644 --- a/libs/subscription/src/index.ts +++ b/libs/subscription/src/index.ts @@ -1 +1,8 @@ -export type Placeholder = unknown; +// Components +export * from "./components/additional-options-card/additional-options-card.component"; +export * from "./components/subscription-card/subscription-card.component"; +export * from "./components/storage-card/storage-card.component"; + +// Types +export * from "./types/bitwarden-subscription"; +export * from "./types/storage"; diff --git a/libs/subscription/src/subscription.spec.ts b/libs/subscription/src/subscription.spec.ts deleted file mode 100644 index 7f0836a5063..00000000000 --- a/libs/subscription/src/subscription.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as lib from "./index"; - -describe("subscription", () => { - // This test will fail until something is exported from index.ts - it("should work", () => { - expect(lib).toBeDefined(); - }); -}); diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts new file mode 100644 index 00000000000..15bf64d03aa --- /dev/null +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -0,0 +1,40 @@ +import { Cart } from "@bitwarden/pricing"; + +import { Storage } from "./storage"; + +export const SubscriptionStatuses = { + Incomplete: "incomplete", + IncompleteExpired: "incomplete_expired", + Trialing: "trialing", + Active: "active", + PastDue: "past_due", + Canceled: "canceled", + Unpaid: "unpaid", +} as const; + +type HasCart = { + cart: Cart; +}; + +type HasStorage = { + storage: Storage; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; + +export type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts new file mode 100644 index 00000000000..beb187250dd --- /dev/null +++ b/libs/subscription/src/types/storage.ts @@ -0,0 +1,5 @@ +export type Storage = { + available: number; + readableUsed: string; + used: number; +}; diff --git a/libs/subscription/test.setup.ts b/libs/subscription/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/subscription/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); From fa451104201110310e0510dd8c28993be0d074c2 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 7 Jan 2026 12:19:54 -0500 Subject: [PATCH 042/101] [PM-29061] Remove Feature Flag 24996 (#18009) * refactor(billing): remove FF from vault banner * refactor(billing): remove from prompt service * chore(billing): remove feature flag * fix(billing): remove premium banner * tests(billing): remove premium banner tests * chore(vault): clean up premium banner dependencies * fix(billing): revert formatting * fix(billing): revert formatting * fix(billing): remove old FF * fix(billling): revert formatting --- .../unified-upgrade-prompt.service.spec.ts | 40 +------ .../unified-upgrade-prompt.service.ts | 26 +---- .../services/vault-banners.service.spec.ts | 103 +---------------- .../services/vault-banners.service.ts | 106 +----------------- .../vault-banners.component.html | 12 -- .../vault-banners.component.spec.ts | 65 +---------- .../vault-banners/vault-banners.component.ts | 22 +--- libs/common/src/enums/feature-flag.enum.ts | 2 - libs/state/src/core/state-definitions.ts | 4 +- 9 files changed, 17 insertions(+), 363 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts index b18e3a7f5c3..3ba2f634785 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -5,8 +5,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; @@ -26,7 +24,6 @@ import { describe("UnifiedUpgradePromptService", () => { let sut: UnifiedUpgradePromptService; const mockAccountService = mock(); - const mockConfigService = mock(); const mockBillingService = mock(); const mockVaultProfileService = mock(); const mockSyncService = mock(); @@ -59,7 +56,6 @@ describe("UnifiedUpgradePromptService", () => { function setupTestService() { sut = new UnifiedUpgradePromptService( mockAccountService, - mockConfigService, mockBillingService, mockVaultProfileService, mockSyncService, @@ -80,7 +76,6 @@ describe("UnifiedUpgradePromptService", () => { beforeEach(() => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockStateProvider.getUserState$.mockReturnValue(of(false)); setupTestService(); @@ -96,7 +91,6 @@ describe("UnifiedUpgradePromptService", () => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockDialogOpen.mockReset(); mockReset(mockDialogService); - mockReset(mockConfigService); mockReset(mockBillingService); mockReset(mockVaultProfileService); mockReset(mockSyncService); @@ -112,11 +106,10 @@ describe("UnifiedUpgradePromptService", () => { mockStateProvider.getUserState$.mockReturnValue(of(false)); mockStateProvider.setUserState.mockResolvedValue(undefined); }); - it("should subscribe to account and feature flag observables when checking display conditions", async () => { + it("should subscribe to account observables when checking display conditions", async () => { // Arrange mockPlatformUtilsService.isSelfHost.mockReturnValue(false); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); - mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); setupTestService(); @@ -125,34 +118,12 @@ describe("UnifiedUpgradePromptService", () => { await sut.displayUpgradePromptConditionally(); // Assert - expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, - ); expect(mockAccountService.activeAccount$).toBeDefined(); }); - it("should not show dialog when feature flag is disabled", async () => { - // Arrange - mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); - mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); - mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - const recentDate = new Date(); - recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old - mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); - - setupTestService(); - // Act - const result = await sut.displayUpgradePromptConditionally(); - - // Assert - expect(result).toBeNull(); - expect(mockDialogOpen).not.toHaveBeenCalled(); - }); it("should not show dialog when user has premium", async () => { // Arrange mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); setupTestService(); @@ -167,7 +138,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when user has any organization membership", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any])); mockPlatformUtilsService.isSelfHost.mockReturnValue(false); @@ -183,7 +153,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when profile is older than 5 minutes", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const oldDate = new Date(); @@ -202,7 +171,6 @@ describe("UnifiedUpgradePromptService", () => { it("should show dialog when all conditions are met", async () => { //Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -224,7 +192,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when account is null/undefined", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); accountSubject.next(null); // Set account to null setupTestService(); @@ -238,7 +205,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when profile creation date is unavailable", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); @@ -256,7 +222,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when running in self-hosted environment", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); const recentDate = new Date(); @@ -275,7 +240,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when user has previously dismissed the modal", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -295,7 +259,6 @@ describe("UnifiedUpgradePromptService", () => { it("should save dismissal state when user closes the dialog", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -320,7 +283,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not save dismissal state when user upgrades to premium", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts index 3ea8f19341d..f5a32483a4d 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -6,8 +6,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; @@ -38,7 +36,6 @@ export class UnifiedUpgradePromptService { private unifiedUpgradeDialogRef: DialogRef | null = null; constructor( private accountService: AccountService, - private configService: ConfigService, private billingAccountProfileStateService: BillingAccountProfileStateService, private vaultProfileService: VaultProfileService, private syncService: SyncService, @@ -70,26 +67,13 @@ export class UnifiedUpgradePromptService { isProfileLessThanFiveMinutesOld$, hasOrganizations$, this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), hasDismissedModal$, ]).pipe( - map( - ([ - isProfileLessThanFiveMinutesOld, - hasOrganizations, - hasPremium, - isFlagEnabled, - hasDismissed, - ]) => { - return ( - isProfileLessThanFiveMinutesOld && - !hasOrganizations && - !hasPremium && - isFlagEnabled && - !hasDismissed - ); - }, - ), + map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, hasDismissed]) => { + return ( + isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && !hasDismissed + ); + }), ); }), take(1), diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 2ba9dd6fad4..20148018c39 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from "@angular/core/testing"; -import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs"; +import { BehaviorSubject } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -17,11 +17,7 @@ import { import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { - PREMIUM_BANNER_REPROMPT_KEY, - VaultBannersService, - VisibleVaultBanner, -} from "./vault-banners.service"; +import { VaultBannersService, VisibleVaultBanner } from "./vault-banners.service"; describe("VaultBannersService", () => { let service: VaultBannersService; @@ -79,101 +75,6 @@ describe("VaultBannersService", () => { jest.useRealTimers(); }); - describe("Premium", () => { - it("waits until sync is completed before showing premium banner", async () => { - hasPremiumFromAnySource$.next(false); - isSelfHost.mockReturnValue(false); - lastSync$.next(null); - - service = TestBed.inject(VaultBannersService); - - const premiumBanner$ = service.shouldShowPremiumBanner$(userId); - - // Should not emit when sync is null - await expect(firstValueFrom(premiumBanner$.pipe(take(1), timeout(100)))).rejects.toThrow(); - - // Should emit when sync is completed - lastSync$.next(new Date("2024-05-14")); - expect(await firstValueFrom(premiumBanner$)).toBe(true); - }); - - it("does not show a premium banner for self-hosted users", async () => { - hasPremiumFromAnySource$.next(false); - isSelfHost.mockReturnValue(true); - - service = TestBed.inject(VaultBannersService); - - expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false); - }); - - it("does not show a premium banner when they have access to premium", async () => { - hasPremiumFromAnySource$.next(true); - isSelfHost.mockReturnValue(false); - - service = TestBed.inject(VaultBannersService); - - expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false); - }); - - describe("dismissing", () => { - beforeEach(async () => { - jest.useFakeTimers(); - const date = new Date("2023-06-08"); - date.setHours(0, 0, 0, 0); - jest.setSystemTime(date.getTime()); - - service = TestBed.inject(VaultBannersService); - await service.dismissBanner(userId, VisibleVaultBanner.Premium); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("updates state on first dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneWeekLater = new Date("2023-06-15"); - oneWeekLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 1, - nextPromptDate: oneWeekLater.getTime(), - }); - }); - - it("updates state on second dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneMonthLater = new Date("2023-07-08"); - oneMonthLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 2, - nextPromptDate: oneMonthLater.getTime(), - }); - }); - - it("updates state on third dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneYearLater = new Date("2024-06-08"); - oneYearLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 3, - nextPromptDate: oneYearLater.getTime(), - }); - }); - }); - }); - describe("OutdatedBrowser", () => { beforeEach(async () => { // Hardcode `MSIE` in userAgent string diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts index 1c53274d9d7..6371f78c0f5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -7,7 +7,6 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider, - PREMIUM_BANNER_DISK_LOCAL, BANNERS_DISMISSED_DISK, UserKeyDefinition, SingleUserState, @@ -18,30 +17,14 @@ import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; export const VisibleVaultBanner = { OutdatedBrowser: "outdated-browser", - Premium: "premium", VerifyEmail: "verify-email", PendingAuthRequest: "pending-auth-request", } as const; export type VisibleVaultBanner = UnionOfValues; -type PremiumBannerReprompt = { - numberOfDismissals: number; - /** Timestamp representing when to show the prompt next */ - nextPromptDate: number; -}; - /** Banners that will be re-shown on a new session */ -type SessionBanners = Omit; - -export const PREMIUM_BANNER_REPROMPT_KEY = new UserKeyDefinition( - PREMIUM_BANNER_DISK_LOCAL, - "bannerReprompt", - { - deserializer: (bannerReprompt) => bannerReprompt, - clearOn: [], // Do not clear user tutorials - }, -); +type SessionBanners = VisibleVaultBanner; export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition( BANNERS_DISMISSED_DISK, @@ -76,33 +59,6 @@ export class VaultBannersService { return pendingAuthRequests.length > 0 && !alreadyDismissed; } - shouldShowPremiumBanner$(userId: UserId): Observable { - const premiumBannerState = this.premiumBannerState(userId); - const premiumSources$ = combineLatest([ - this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), - premiumBannerState.state$, - ]); - - return this.syncService.lastSync$(userId).pipe( - filter((lastSync) => lastSync !== null), - take(1), // Wait until the first sync is complete before considering the premium status - mergeMap(() => premiumSources$), - map(([canAccessPremium, dismissedState]) => { - const shouldShowPremiumBanner = - !canAccessPremium && !this.platformUtilsService.isSelfHost(); - - // Check if nextPromptDate is in the past passed - if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) { - const nextPromptDate = new Date(dismissedState.nextPromptDate); - const now = new Date(); - return now >= nextPromptDate; - } - - return shouldShowPremiumBanner; - }), - ); - } - /** Returns true when the update browser banner should be shown */ async shouldShowUpdateBrowserBanner(userId: UserId): Promise { const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1; @@ -128,23 +84,11 @@ export class VaultBannersService { /** Dismiss the given banner and perform any respective side effects */ async dismissBanner(userId: UserId, banner: SessionBanners): Promise { - if (banner === VisibleVaultBanner.Premium) { - await this.dismissPremiumBanner(userId); - } else { - await this.sessionBannerState(userId).update((current) => { - const bannersDismissed = current ?? []; + await this.sessionBannerState(userId).update((current) => { + const bannersDismissed = current ?? []; - return [...bannersDismissed, banner]; - }); - } - } - - /** - * - * @returns a SingleUserState for the premium banner reprompt state - */ - private premiumBannerState(userId: UserId): SingleUserState { - return this.stateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY); + return [...bannersDismissed, banner]; + }); } /** @@ -161,42 +105,4 @@ export class VaultBannersService { // use nullish coalescing to default to an empty array return (await firstValueFrom(this.sessionBannerState(userId).state$)) ?? []; } - - /** Increment dismissal state of the premium banner */ - private async dismissPremiumBanner(userId: UserId): Promise { - await this.premiumBannerState(userId).update((current) => { - const numberOfDismissals = current?.numberOfDismissals ?? 0; - const now = new Date(); - - // Set midnight of the current day - now.setHours(0, 0, 0, 0); - - // First dismissal, re-prompt in 1 week - if (numberOfDismissals === 0) { - now.setDate(now.getDate() + 7); - return { - numberOfDismissals: 1, - nextPromptDate: now.getTime(), - }; - } - - // Second dismissal, re-prompt in 1 month - if (numberOfDismissals === 1) { - now.setMonth(now.getMonth() + 1); - return { - numberOfDismissals: 2, - nextPromptDate: now.getTime(), - }; - } - - // 3+ dismissals, re-prompt each year - // Avoid day/month edge cases and only increment year - const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()); - nextYear.setHours(0, 0, 0, 0); - return { - numberOfDismissals: numberOfDismissals + 1, - nextPromptDate: nextYear.getTime(), - }; - }); - } } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html index 44b2975ee19..f197853ac18 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -44,15 +44,3 @@ (onDismiss)="dismissBanner(VisibleVaultBanner.VerifyEmail)" (onVerified)="dismissBanner(VisibleVaultBanner.VerifyEmail)" > - - - {{ "premiumUpgradeUnlockFeatures" | i18n }} - - {{ "goPremium" | i18n }} - - diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts index 7730ab974fb..786f9a76a20 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { By } from "@angular/platform-browser"; import { RouterTestingModule } from "@angular/router/testing"; import { mock } from "jest-mock-extended"; import { BehaviorSubject, Subject } from "rxjs"; @@ -8,15 +7,13 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; -import { BannerComponent, BannerModule } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; import { SharedModule } from "../../../shared"; @@ -30,11 +27,9 @@ describe("VaultBannersComponent", () => { let messageSubject: Subject<{ command: string }>; const premiumBanner$ = new BehaviorSubject(false); const pendingAuthRequest$ = new BehaviorSubject(false); - const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject(false); const mockUserId = Utils.newGuid() as UserId; const bannerService = mock({ - shouldShowPremiumBanner$: jest.fn((userId: UserId) => premiumBanner$), shouldShowUpdateBrowserBanner: jest.fn(), shouldShowVerifyEmailBanner: jest.fn(), shouldShowPendingAuthRequestBanner: jest.fn((userId: UserId) => @@ -88,17 +83,6 @@ describe("VaultBannersComponent", () => { allMessages$: messageSubject.asObservable(), }), }, - { - provide: ConfigService, - useValue: mock({ - getFeatureFlag$: jest.fn((flag: FeatureFlag) => { - if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) { - return PM24996_ImplementUpgradeFromFreeDialogFlag$; - } - return new BehaviorSubject(false); - }), - }), - }, ], }) .overrideProvider(VaultBannersService, { useValue: bannerService }) @@ -112,53 +96,6 @@ describe("VaultBannersComponent", () => { fixture.detectChanges(); }); - describe("premiumBannerVisible$", () => { - beforeEach(() => { - // Reset feature flag to default (false) before each test - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - }); - - it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => { - premiumBanner$.next(true); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner.componentInstance.bannerType()).toBe("premium"); - }); - - it("hides premium banner when feature flag is enabled", async () => { - premiumBanner$.next(true); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - - it("dismisses premium banner when shouldShowPremiumBanner is false", async () => { - premiumBanner$.next(false); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - - it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => { - premiumBanner$.next(false); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - }); - describe("determineVisibleBanner", () => { [ { diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index 80626d258f8..9ddfdedd61b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -1,14 +1,11 @@ import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { combineLatest, filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { filter, firstValueFrom, map } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; -import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; @@ -32,7 +29,6 @@ import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banner }) export class VaultBannersComponent implements OnInit { visibleBanners: VisibleVaultBanner[] = []; - premiumBannerVisible$: Observable; VisibleVaultBanner = VisibleVaultBanner; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -45,23 +41,7 @@ export class VaultBannersComponent implements OnInit { private router: Router, private accountService: AccountService, private messageListener: MessageListener, - private configService: ConfigService, ) { - this.premiumBannerVisible$ = this.activeUserId$.pipe( - filter((userId): userId is UserId => userId != null), - switchMap((userId) => - combineLatest([ - this.vaultBannerService.shouldShowPremiumBanner$(userId), - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), - ]).pipe( - map( - ([shouldShowBanner, PM24996_ImplementUpgradeFromFreeDialogEnabled]) => - shouldShowBanner && !PM24996_ImplementUpgradeFromFreeDialogEnabled, - ), - ), - ), - ); - // Listen for auth request messages and show banner immediately this.messageListener.allMessages$ .pipe( diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 080b52de05d..5dfee6054b2 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,7 +27,6 @@ export enum FeatureFlag { TrialPaymentOptional = "PM-8163-trial-payment", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", - PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", @@ -137,7 +136,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, - [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 445e5fecde7..ae6938b2069 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -198,9 +198,7 @@ export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", { export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", { browser: "memory-large-object", }); -export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerReprompt", "disk", { - web: "disk-local", -}); + export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk"); export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); From 0e2748784b05d022cbd4cf7965620a03ba4dbb52 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:29:12 -0600 Subject: [PATCH 043/101] [PM-25385] Remove unlock-with-master-password-unlock-data flag (#18010) * remove feature flag from lock component * Add missing windowHidden desktop feature * Remove the flag from CLI unlock * Remove the flag from enum file --- apps/cli/src/base-program.ts | 3 - .../commands/unlock.command.spec.ts | 213 ++----- .../key-management/commands/unlock.command.ts | 53 +- apps/cli/src/oss-serve-configurator.ts | 3 - apps/cli/src/program.ts | 3 - libs/common/src/enums/feature-flag.enum.ts | 2 - .../src/lock/components/lock.component.html | 76 +-- .../lock/components/lock.component.spec.ts | 527 +----------------- .../src/lock/components/lock.component.ts | 125 ----- .../master-password-lock.component.html | 8 +- .../master-password-lock.component.spec.ts | 30 + .../master-password-lock.component.ts | 39 +- 12 files changed, 129 insertions(+), 953 deletions(-) diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 71c3830b4cc..2ce0d425007 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -172,9 +172,7 @@ export abstract class BaseProgram { } else { const command = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -184,7 +182,6 @@ export abstract class BaseProgram { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); const response = await command.run(null, null); if (!response.success) { diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 50ef414ec37..a722469f7bb 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -3,21 +3,16 @@ import { of } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { UserKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { ConsoleLogService } from "@bitwarden/logging"; import { UserId } from "@bitwarden/user-core"; @@ -32,9 +27,7 @@ describe("UnlockCommand", () => { let command: UnlockCommand; const accountService = mock(); - const masterPasswordService = mock(); const keyService = mock(); - const userVerificationService = mock(); const cryptoFunctionService = mock(); const logService = mock(); const keyConnectorService = mock(); @@ -44,7 +37,6 @@ describe("UnlockCommand", () => { const i18nService = mock(); const encryptedMigrator = mock(); const masterPasswordUnlockService = mock(); - const configService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -73,9 +65,6 @@ describe("UnlockCommand", () => { ); expectedSuccessMessage.raw = b64sessionKey; - // Legacy test data - const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; - beforeEach(async () => { jest.clearAllMocks(); @@ -86,9 +75,7 @@ describe("UnlockCommand", () => { command = new UnlockCommand( accountService, - masterPasswordService, keyService, - userVerificationService, cryptoFunctionService, logService, keyConnectorService, @@ -98,7 +85,6 @@ describe("UnlockCommand", () => { i18nService, encryptedMigrator, masterPasswordUnlockService, - configService, ); }); @@ -133,116 +119,46 @@ describe("UnlockCommand", () => { }, ); - describe("UnlockWithMasterPasswordUnlockData feature flag enabled", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); + it("calls masterPasswordUnlockService successfully", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); - it("calls masterPasswordUnlockService successfully", async () => { - masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - }); - - it("returns error response if unlockWithMasterPassword fails", async () => { - masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( - new Error("Unlock failed"), - ); - - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(false); - expect(response.message).toEqual("Unlock failed"); - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - expect(keyService.setUserKey).not.toHaveBeenCalled(); - }); + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); }); - describe("unlock with feature flag off", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + it("returns error response if unlockWithMasterPassword fails", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( + new Error("Unlock failed"), + ); - it("calls decryptUserKeyWithMasterKey successfully", async () => { - userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - masterKey: mockMasterKey, - } as MasterPasswordVerificationResponse); - masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - }); - - it("returns error response when verifyUserByMasterPassword throws", async () => { - userVerificationService.verifyUserByMasterPassword.mockRejectedValue( - new Error("Verification failed"), - ); - - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(false); - expect(response.message).toEqual("Verification failed"); - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).not.toHaveBeenCalled(); - expect(keyService.setUserKey).not.toHaveBeenCalled(); - }); + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Unlock failed"); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); }); describe("calls convertToKeyConnectorCommand if required", () => { let convertToKeyConnectorSpy: jest.SpyInstance; beforeEach(() => { keyConnectorService.convertAccountRequired$ = of(true); - - // Feature flag on masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); - - // Feature flag off - userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - masterKey: mockMasterKey, - } as MasterPasswordVerificationResponse); - masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); }); - test.each([true, false])("returns failure when feature flag is %s", async (flagValue) => { - configService.getFeatureFlag$.mockReturnValue(of(flagValue)); - + it("returns error on failure", async () => { // Mock the ConvertToKeyConnectorCommand const mockRun = jest.fn().mockResolvedValue({ success: false, message: "convert failed" }); convertToKeyConnectorSpy = jest @@ -257,67 +173,32 @@ describe("UnlockCommand", () => { expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - if (flagValue === true) { - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - } else { - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - } + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); }); - test.each([true, false])( - "returns expected success when feature flag is %s", - async (flagValue) => { - configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + it("returns success on successful conversion", async () => { + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: true }); + const convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); - // Mock the ConvertToKeyConnectorCommand - const mockRun = jest.fn().mockResolvedValue({ success: true }); - const convertToKeyConnectorSpy = jest - .spyOn(ConvertToKeyConnectorCommand.prototype, "run") - .mockImplementation(mockRun); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - - if (flagValue === true) { - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - } else { - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - } - }, - ); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); }); }); }); diff --git a/apps/cli/src/key-management/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts index c88d9ae1cc4..5f82b721d07 100644 --- a/apps/cli/src/key-management/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -4,20 +4,13 @@ import { firstValueFrom } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; -import { MasterKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { Response } from "../../models/response"; @@ -29,9 +22,7 @@ import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.comman export class UnlockCommand { constructor( private accountService: AccountService, - private masterPasswordService: InternalMasterPasswordServiceAbstraction, private keyService: KeyService, - private userVerificationService: UserVerificationService, private cryptoFunctionService: CryptoFunctionService, private logService: ConsoleLogService, private keyConnectorService: KeyConnectorService, @@ -41,7 +32,6 @@ export class UnlockCommand { private i18nService: I18nService, private encryptedMigrator: EncryptedMigrator, private masterPasswordUnlockService: MasterPasswordUnlockService, - private configService: ConfigService, ) {} async run(password: string, cmdOptions: Record) { @@ -61,46 +51,15 @@ export class UnlockCommand { } const userId = activeAccount.id; - if ( - await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.UnlockWithMasterPasswordUnlockData), - ) - ) { - try { - const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( - password, - userId, - ); - - await this.keyService.setUserKey(userKey, userId); - } catch (e) { - return Response.error(e.message); - } - } else { - const email = activeAccount.email; - const verification = { - type: VerificationType.MasterPassword, - secret: password, - } as MasterPasswordVerification; - - let masterKey: MasterKey; - try { - const response = await this.userVerificationService.verifyUserByMasterPassword( - verification, - userId, - email, - ); - masterKey = response.masterKey; - } catch (e) { - // verification failure throws - return Response.error(e.message); - } - - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - masterKey, + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + password, userId, ); + await this.keyService.setUserKey(userKey, userId); + } catch (e) { + return Response.error(e.message); } if (await firstValueFrom(this.keyConnectorService.convertAccountRequired$)) { diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index b2cca2a644b..e0385534cb7 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -166,9 +166,7 @@ export class OssServeConfigurator { ); this.unlockCommand = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -178,7 +176,6 @@ export class OssServeConfigurator { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); this.sendCreateCommand = new SendCreateCommand( diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 870d743095d..7856fc3588c 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -303,9 +303,7 @@ export class Program extends BaseProgram { await this.exitIfNotAuthed(); const command = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -315,7 +313,6 @@ export class Program extends BaseProgram { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); const response = await command.run(password, cmd); this.processResponse(response); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 5dfee6054b2..20da219e8d7 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -38,7 +38,6 @@ export enum FeatureFlag { ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", - UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", @@ -147,7 +146,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, - [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 71201361a0c..c1577b76a4d 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -121,11 +121,7 @@ - @if ( - (unlockWithMasterPasswordUnlockDataFlag$ | async) && - unlockOptions.masterPassword.enabled && - activeUnlockOption === UnlockOption.MasterPassword - ) { + @if (unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword) { - } @else { - -
- - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "or" | i18n }}

- - - - - - - - - - -
-
-
} diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 5d35746ff19..054212f8851 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -1,10 +1,8 @@ -import { DebugElement } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { ZXCVBNResult } from "zxcvbn"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -13,20 +11,13 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; -import { - MasterPasswordVerification, - MasterPasswordVerificationResponse, -} from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -36,7 +27,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { UserKey } from "@bitwarden/common/types/key"; import { AnonLayoutWrapperDataService, AsyncActionsModule, @@ -94,7 +85,6 @@ describe("LockComponent", () => { const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); const mockEncryptedMigrator = mock(); - const mockConfigService = mock(); const mockActivatedRoute = { snapshot: { paramMap: { @@ -161,7 +151,6 @@ describe("LockComponent", () => { { provide: BroadcasterService, useValue: mockBroadcasterService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, - { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -171,207 +160,6 @@ describe("LockComponent", () => { component = fixture.componentInstance; }); - describe("when master password unlock is active", () => { - let form: DebugElement; - - beforeEach(async () => { - const unlockOptions: UnlockOptions = { - masterPassword: { enabled: true }, - pin: { enabled: false }, - biometrics: { - enabled: false, - biometricsStatus: BiometricsStatus.NotEnabledLocally, - }, - }; - - component.activeUnlockOption = UnlockOption.MasterPassword; - mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(unlockOptions)); - await mockAccountService.switchAccount(userId); - mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); - - mockI18nService.t.mockImplementation((key: string) => { - switch (key) { - case "unlock": - return "Unlock"; - case "logOut": - return "Log Out"; - case "logOutConfirmation": - return "Confirm Log Out"; - case "masterPass": - return "Master Password"; - } - return ""; - }); - - // Trigger ngOnInit - fixture.detectChanges(); - - // Wait for html loading to complete - await firstValueFrom( - interval(10).pipe( - map(() => component["loading"]), - takeWhile((loading) => loading, true), - timeout(5000), - ), - ); - - // Wait for html to render - fixture.detectChanges(); - - form = fixture.debugElement.query(By.css("form")); - }); - - describe("form rendering", () => { - it("should render form with label", () => { - expect(form).toBeTruthy(); - expect(form.nativeElement).toBeInstanceOf(HTMLFormElement); - - const bitLabel = form.query(By.css("bit-label")); - expect(bitLabel).toBeTruthy(); - expect(bitLabel.nativeElement).toBeInstanceOf(HTMLElement); - expect((bitLabel.nativeElement as HTMLElement).textContent?.trim()).toBe("Master Password"); - }); - - it("should render master password input field", () => { - const input = form.query(By.css('input[formControlName="masterPassword"]')); - - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - const inputElement = input.nativeElement as HTMLInputElement; - expect(inputElement.type).toEqual("password"); - expect(inputElement.name).toEqual("masterPassword"); - expect(inputElement.required).toEqual(true); - expect(inputElement.attributes).toHaveProperty("bitInput"); - }); - - it("should render password toggle button", () => { - const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); - - expect(toggleButton).toBeTruthy(); - expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; - expect(toggleButtonElement.type).toEqual("button"); - expect(toggleButtonElement.attributes).toHaveProperty("bitIconButton"); - }); - - it("should render unlock submit button", () => { - const submitButton = form.query(By.css('button[type="submit"]')); - - expect(submitButton).toBeTruthy(); - expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; - expect(submitButtonElement.type).toEqual("submit"); - expect(submitButtonElement.attributes).toHaveProperty("bitButton"); - expect(submitButtonElement.attributes).toHaveProperty("bitFormButton"); - expect(submitButtonElement.textContent?.trim()).toEqual("Unlock"); - }); - - it("should render logout button", () => { - const logoutButton = form.query( - By.css('button[type="button"]:not([bitPasswordInputToggle])'), - ); - - expect(logoutButton).toBeTruthy(); - expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; - expect(logoutButtonElement.type).toEqual("button"); - expect(logoutButtonElement.textContent?.trim()).toEqual("Log Out"); - }); - }); - - describe("unlock", () => { - it("should unlock with master password when unlock button is clicked", async () => { - const unlockViaMasterPasswordFunction = jest - .spyOn(component, "unlockViaMasterPassword") - .mockImplementation(); - const submitButton = form.query(By.css('button[type="submit"]')); - expect(submitButton).toBeTruthy(); - expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; - submitButtonElement.click(); - - expect(unlockViaMasterPasswordFunction).toHaveBeenCalled(); - }); - }); - - describe("logout", () => { - it("should logout when logout button is clicked", async () => { - const logOut = jest.spyOn(component, "logOut").mockImplementation(); - const logoutButton = form.query( - By.css('button[type="button"]:not([bitPasswordInputToggle])'), - ); - - expect(logoutButton).toBeTruthy(); - expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; - - logoutButtonElement.click(); - - expect(logOut).toHaveBeenCalled(); - }); - }); - - describe("password input", () => { - it("should bind form input to masterPassword form control", async () => { - const input = form.query(By.css('input[formControlName="masterPassword"]')); - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - expect(component.formGroup).toBeTruthy(); - const masterPasswordControl = component.formGroup!.get("masterPassword"); - expect(masterPasswordControl).toBeTruthy(); - - masterPasswordControl!.setValue("test-password"); - fixture.detectChanges(); - - const inputElement = input.nativeElement as HTMLInputElement; - expect(inputElement.value).toEqual("test-password"); - }); - - it("should validate required master password field", async () => { - const formGroup = component.formGroup; - - // Initially form should be invalid (empty required field) - expect(formGroup?.invalid).toEqual(true); - expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); - - // Set a value - formGroup?.get("masterPassword")?.setValue("test-password"); - - expect(formGroup?.invalid).toEqual(false); - expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); - }); - - it("should toggle password visibility when toggle button is clicked", async () => { - const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); - expect(toggleButton).toBeTruthy(); - expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; - const input = form.query(By.css('input[formControlName="masterPassword"]')); - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - const inputElement = input.nativeElement as HTMLInputElement; - - // Initially password should be hidden - expect(component.showPassword).toEqual(false); - expect(inputElement.type).toEqual("password"); - - // Click toggle button - toggleButtonElement.click(); - fixture.detectChanges(); - - expect(component.showPassword).toEqual(true); - expect(inputElement.type).toEqual("text"); - - // Click toggle button again - toggleButtonElement.click(); - fixture.detectChanges(); - - expect(component.showPassword).toEqual(false); - expect(inputElement.type).toEqual("password"); - }); - }); - }); - describe("successfulMasterPasswordUnlock", () => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const masterPassword = "test-password"; @@ -519,317 +307,6 @@ describe("LockComponent", () => { } }); - describe("unlockViaMasterPassword", () => { - const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; - const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { - masterKey: mockMasterKey, - email: "test-email@example.com", - policyOptions: null, - }; - const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const masterPassword = "test-password"; - - beforeEach(async () => { - mockI18nService.t.mockImplementation((key: string) => { - switch (key) { - case "errorOccurred": - return "Error Occurred"; - case "masterPasswordRequired": - return "Master Password is required"; - case "invalidMasterPassword": - return "Invalid Master Password"; - } - return ""; - }); - - component.buildMasterPasswordForm(); - component.formGroup!.controls.masterPassword.setValue(masterPassword); - component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); - mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue( - masterPasswordVerificationResponse, - ); - mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); - }); - - it("should not unlock and show password invalid toast when master password is empty", async () => { - component.formGroup!.controls.masterPassword.setValue(""); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Master Password is required", - }); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when no active account", async () => { - component.activeAccount = null; - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when no form group", async () => { - component.formGroup = null; - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when input password verification failed due to invalid password", async () => { - mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce( - new Error("invalid password"), - ); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Invalid Master Password", - }); - expect(mockUserVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: masterPassword, - } as MasterPasswordVerification, - userId, - component.activeAccount!.email, - ); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when valid password but user have no user key", async () => { - mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Invalid Master Password", - }); - expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - userId, - ); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should unlock and set user key and sync when valid password", async () => { - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); - }); - - it.each([ - [false, undefined, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], - [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], - ])( - "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password", - async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - - if (masterPasswordPolicyOptions?.enforceOnLogin) { - const passwordStrengthResult = mockPasswordStrengthService.getPasswordStrength( - masterPassword, - component.activeAccount!.email, - ); - const evaluated = mockPolicyService.evaluateMasterPassword( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - if (!evaluated) { - await mockMasterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } - } - - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); - }); - - mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - ...masterPasswordVerificationResponse, - policyOptions: - masterPasswordPolicyOptions != null - ? new MasterPasswordPolicyResponse({ - EnforceOnLogin: masterPasswordPolicyOptions.enforceOnLogin, - }) - : null, - } as MasterPasswordVerificationResponse); - const passwordStrengthResult = { score: 1 } as ZXCVBNResult; - mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); - mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - if (masterPasswordPolicyOptions?.enforceOnLogin) { - expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( - masterPassword, - component.activeAccount!.email, - ); - expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - } - if (forceSetPassword) { - expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } else { - expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - [false, undefined, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], - [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], - ])( - "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", - async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { - mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( - of(masterPasswordPolicyOptions), - ); - const passwordStrengthResult = { score: 1 } as ZXCVBNResult; - mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); - mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); - if (masterPasswordPolicyOptions?.enforceOnLogin) { - expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( - masterPassword, - component.activeAccount!.email, - ); - expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - } - if (forceSetPassword) { - expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } else { - expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - [true, ClientType.Browser], - [false, ClientType.Cli], - [false, ClientType.Desktop], - [false, ClientType.Web], - ])( - "should unlock and navigate by url to previous url = %o when client type = %o and previous url was set", - async (shouldNavigate, clientType) => { - const previousUrl = "/test-url"; - component.clientType = clientType; - mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - if (shouldNavigate) { - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); - } else { - expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - ["/tabs/current", ClientType.Browser], - [undefined, ClientType.Cli], - ["vault", ClientType.Desktop], - ["vault", ClientType.Web], - ])( - "should unlock and navigate to success url = %o when client type = %o", - async (navigateUrl, clientType) => { - component.clientType = clientType; - mockLockComponentService.getPreviousUrl.mockReturnValue(null); - - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); - await mockRouter.navigate([navigateUrl]); - }); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); - }, - ); - - it("should unlock and close browser extension popout on firefox extension", async () => { - component.shouldClosePopout = true; - mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); - - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded( - component.activeAccount!.id, - ); - mockLockComponentService.closeBrowserExtensionPopout(); - }); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); - }); - - function assertUnlocked() { - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - userId, - ); - expect(mockKeyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId); - expect(mockDeviceTrustService.trustDeviceIfRequired).toHaveBeenCalledWith(userId); - expect(mockBiometricStateService.resetUserPromptCancelled).toHaveBeenCalled(); - expect(mockMessagingService.send).toHaveBeenCalledWith("unlocked"); - expect(mockSyncService.fullSync).toHaveBeenCalledWith(false); - expect(mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith( - userId, - ); - } - }); - describe("logOut", () => { it("should log out user and redirect to login page when dialog confirmed", async () => { mockDialogService.openSimpleDialog.mockResolvedValue(true); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 4b43f0ec784..03ab6033441 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -10,7 +10,6 @@ import { mergeMap, Subject, switchMap, - take, takeUntil, tap, } from "rxjs"; @@ -20,22 +19,14 @@ import { LogoutService } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - MasterPasswordVerification, - MasterPasswordVerificationResponse, -} from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -115,10 +106,6 @@ export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; - protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.UnlockWithMasterPasswordUnlockData, - ); - activeAccount: Account | null = null; clientType?: ClientType; @@ -144,7 +131,6 @@ export class LockComponent implements OnInit, OnDestroy { biometricUnlockBtnText?: string; - // masterPassword = ""; showPassword = false; private enforcedMasterPasswordOptions?: MasterPasswordPolicyOptions = undefined; @@ -164,7 +150,6 @@ export class LockComponent implements OnInit, OnDestroy { constructor( private accountService: AccountService, private pinService: PinServiceAbstraction, - private userVerificationService: UserVerificationService, private keyService: KeyService, private platformUtilsService: PlatformUtilsService, private router: Router, @@ -189,7 +174,6 @@ export class LockComponent implements OnInit, OnDestroy { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private encryptedMigrator: EncryptedMigrator, - private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -246,21 +230,10 @@ export class LockComponent implements OnInit, OnDestroy { .subscribe((activeUnlockOption: UnlockOptionValue | null) => { if (activeUnlockOption === UnlockOption.Pin) { this.buildPinForm(); - } else if (activeUnlockOption === UnlockOption.MasterPassword) { - this.buildMasterPasswordForm(); } }); } - buildMasterPasswordForm() { - this.formGroup = this.formBuilder.group( - { - masterPassword: ["", [Validators.required]], - }, - { updateOn: "submit" }, - ); - } - private buildPinForm() { this.formGroup = this.formBuilder.group( { @@ -406,8 +379,6 @@ export class LockComponent implements OnInit, OnDestroy { if (this.activeUnlockOption === UnlockOption.Pin) { return await this.unlockViaPin(); } - - await this.unlockViaMasterPassword(); }; async logOut() { @@ -489,25 +460,6 @@ export class LockComponent implements OnInit, OnDestroy { } } - //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. - togglePassword() { - this.showPassword = !this.showPassword; - const input = document.getElementById( - this.unlockOptions?.pin.enabled ? "pin" : "masterPassword", - ); - - if (input == null) { - return; - } - - if (this.ngZone.isStable) { - input.focus(); - } else { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus()); - } - } - private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -565,83 +517,6 @@ export class LockComponent implements OnInit, OnDestroy { } } - // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. - private validateMasterPassword(): boolean { - if (this.formGroup?.invalid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordRequired"), - }); - return false; - } - - return true; - } - - // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. - async unlockViaMasterPassword() { - if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { - return; - } - - const masterPassword = this.formGroup.controls.masterPassword.value; - - const verification = { - type: VerificationType.MasterPassword, - secret: masterPassword, - } as MasterPasswordVerification; - - let passwordValid = false; - let masterPasswordVerificationResponse: MasterPasswordVerificationResponse | null = null; - try { - masterPasswordVerificationResponse = - await this.userVerificationService.verifyUserByMasterPassword( - verification, - this.activeAccount.id, - this.activeAccount.email, - ); - - if (masterPasswordVerificationResponse?.policyOptions != null) { - this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse( - masterPasswordVerificationResponse.policyOptions, - ); - } else { - this.enforcedMasterPasswordOptions = undefined; - } - - passwordValid = true; - } catch (e) { - this.logService.error(e); - } - - if (!passwordValid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidMasterPassword"), - }); - return; - } - - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - masterPasswordVerificationResponse!.masterKey, - this.activeAccount.id, - ); - if (userKey == null) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidMasterPassword"), - }); - return; - } - - await this.setUserKeyAndContinue(userKey, { - passwordEvaluation: { masterPassword }, - }); - } - async successfulMasterPasswordUnlock(event: { userKey: UserKey; masterPassword: string; diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 185fb0666c4..4c7cdd48353 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -11,7 +11,13 @@ required appInputVerbatim /> - +
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index 71287e7684c..dabab3e558a 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -6,10 +6,12 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserKey } from "@bitwarden/common/types/key"; @@ -21,6 +23,7 @@ import { ToastService, } from "@bitwarden/components"; import { BiometricsStatus } from "@bitwarden/key-management"; +import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; @@ -36,6 +39,8 @@ describe("MasterPasswordLockComponent", () => { const i18nService = mock(); const toastService = mock(); const logService = mock(); + const platformUtilsService = mock(); + const messageListener = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -103,6 +108,8 @@ describe("MasterPasswordLockComponent", () => { { provide: I18nService, useValue: i18nService }, { provide: ToastService, useValue: toastService }, { provide: LogService, useValue: logService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: MessageListener, useValue: messageListener }, ], }).compileComponents(); @@ -281,6 +288,29 @@ describe("MasterPasswordLockComponent", () => { }); }); + describe("ngOnInit", () => { + test.each([ClientType.Browser, ClientType.Web])( + "does nothing when client type is %s", + async (clientType) => { + platformUtilsService.getClientType.mockReturnValue(clientType); + messageListener.messages$.mockReturnValue(of({})); + + await component.ngOnInit(); + + expect(messageListener.messages$).not.toHaveBeenCalled(); + }, + ); + + it("subscribes to windowHidden messages when client type is Desktop", async () => { + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + messageListener.messages$.mockReturnValue(of({})); + + await component.ngOnInit(); + + expect(messageListener.messages$).toHaveBeenCalledWith(new CommandDefinition("windowHidden")); + }); + }); + describe("logout", () => { it("emits logOut event when logout button is clicked", () => { const setup = setupComponent(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index ff1e7f53e5f..1237869717f 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -1,12 +1,23 @@ -import { Component, computed, inject, input, model, output } from "@angular/core"; +import { + Component, + computed, + inject, + input, + model, + OnDestroy, + OnInit, + output, +} from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, @@ -17,6 +28,7 @@ import { } from "@bitwarden/components"; import { BiometricsStatus } from "@bitwarden/key-management"; import { LogService } from "@bitwarden/logging"; +import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { @@ -39,12 +51,14 @@ import { IconButtonModule, ], }) -export class MasterPasswordLockComponent { +export class MasterPasswordLockComponent implements OnInit, OnDestroy { private readonly accountService = inject(AccountService); private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); private readonly i18nService = inject(I18nService); private readonly toastService = inject(ToastService); private readonly logService = inject(LogService); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly messageListener = inject(MessageListener); UnlockOption = UnlockOption; readonly activeUnlockOption = model.required(); @@ -64,6 +78,9 @@ export class MasterPasswordLockComponent { successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); logOut = output(); + protected showPassword = false; + private destroy$ = new Subject(); + formGroup = new FormGroup({ masterPassword: new FormControl("", { validators: [Validators.required], @@ -71,6 +88,22 @@ export class MasterPasswordLockComponent { }), }); + async ngOnInit(): Promise { + if (this.platformUtilsService.getClientType() === ClientType.Desktop) { + this.messageListener + .messages$(new CommandDefinition("windowHidden")) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.showPassword = false; + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + submit = async () => { this.formGroup.markAllAsTouched(); const masterPassword = this.formGroup.controls.masterPassword.value; From 9ba9c89ee692530714853cc7be894e573ede0da1 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:00:31 -0700 Subject: [PATCH 044/101] Allow unmaintained crates in transient deps (#18241) --- apps/desktop/desktop_native/deny.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/desktop_native/deny.toml b/apps/desktop/desktop_native/deny.toml index 7d7a126f694..66b80e9984c 100644 --- a/apps/desktop/desktop_native/deny.toml +++ b/apps/desktop/desktop_native/deny.toml @@ -1,9 +1,10 @@ # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html [advisories] +# Allow unmaintained crates in transient deps but not direct +unmaintained = "workspace" ignore = [ # Vulnerability in `rsa` crate: https://rustsec.org/advisories/RUSTSEC-2023-0071.html { id = "RUSTSEC-2023-0071", reason = "There is no fix available yet." }, - { id = "RUSTSEC-2024-0436", reason = "paste crate is unmaintained."} ] # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html From 196db093b2cd48a219fa4201b880b8340056ae61 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:54:46 -0700 Subject: [PATCH 045/101] Desktop autotype remove SHIFT from valid modifier keys (#17347) Removal of SHIFT from valid modifier keys. As it stands, we allow [SHIFT + ``] , which would prevent users from capitalizing letters. As a result, the default shortcut has to change (because it included SHIFT). Changed to CONTROL + ALT + b --- .../desktop_native/autotype/src/lib.rs | 6 ++ .../autotype/src/modifier_keys.rs | 45 ++++++++++++++ .../autotype/src/windows/mod.rs | 1 - .../autotype/src/windows/type_input.rs | 47 +++++++------- .../app/accounts/settings.component.spec.ts | 2 +- .../autotype-shortcut.component.html | 2 +- .../autotype-shortcut.component.spec.ts | 61 +++++-------------- .../components/autotype-shortcut.component.ts | 28 +++++---- .../models/main-autotype-keyboard-shortcut.ts | 24 ++++++-- .../services/desktop-autotype.service.ts | 10 ++- apps/desktop/src/locales/en/messages.json | 4 +- 11 files changed, 133 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/desktop_native/autotype/src/modifier_keys.rs diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 4b9e65180e6..917f0f797b6 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -1,5 +1,11 @@ use anyhow::Result; +#[cfg(target_os = "windows")] +mod modifier_keys; + +#[cfg(target_os = "windows")] +pub(crate) use modifier_keys::*; + #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "windows", path = "windows/mod.rs")] diff --git a/apps/desktop/desktop_native/autotype/src/modifier_keys.rs b/apps/desktop/desktop_native/autotype/src/modifier_keys.rs new file mode 100644 index 00000000000..c451a3b25e4 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/modifier_keys.rs @@ -0,0 +1,45 @@ +// Electron modifier keys +// +pub(crate) const CONTROL_KEY_STR: &str = "Control"; +pub(crate) const ALT_KEY_STR: &str = "Alt"; +pub(crate) const SUPER_KEY_STR: &str = "Super"; + +// numeric values for modifier keys +pub(crate) const CONTROL_KEY: u16 = 0x11; +pub(crate) const ALT_KEY: u16 = 0x12; +pub(crate) const SUPER_KEY: u16 = 0x5B; + +/// A mapping of to +static MODIFIER_KEYS: [(&str, u16); 3] = [ + (CONTROL_KEY_STR, CONTROL_KEY), + (ALT_KEY_STR, ALT_KEY), + (SUPER_KEY_STR, SUPER_KEY), +]; + +/// Provides a mapping of the valid modifier keys' electron +/// string representation to the numeric representation. +pub(crate) fn get_numeric_modifier_key(modifier: &str) -> Option { + for (modifier_str, modifier_num) in MODIFIER_KEYS { + if modifier_str == modifier { + return Some(modifier_num); + } + } + None +} + +#[cfg(test)] +mod test { + use super::get_numeric_modifier_key; + + #[test] + fn valid_modifier_keys() { + assert_eq!(get_numeric_modifier_key("Control").unwrap(), 0x11); + assert_eq!(get_numeric_modifier_key("Alt").unwrap(), 0x12); + assert_eq!(get_numeric_modifier_key("Super").unwrap(), 0x5B); + } + + #[test] + fn does_not_contain_invalid_modifier_keys() { + assert!(get_numeric_modifier_key("Shift").is_none()); + } +} diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs index 9cd9bc0cbe5..ed985749303 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/mod.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -44,7 +44,6 @@ pub fn get_foreground_window_title() -> Result { /// - Control /// - Alt /// - Super -/// - Shift /// - \[a-z\]\[A-Z\] struct KeyboardShortcutInput(INPUT); diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index b62dd7290d1..f7879e676bf 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -6,11 +6,7 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ }; use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations}; - -const SHIFT_KEY_STR: &str = "Shift"; -const CONTROL_KEY_STR: &str = "Control"; -const ALT_KEY_STR: &str = "Alt"; -const LEFT_WINDOWS_KEY_STR: &str = "Super"; +use crate::get_numeric_modifier_key; const IS_VIRTUAL_KEY: bool = true; const IS_REAL_KEY: bool = false; @@ -88,22 +84,19 @@ impl TryFrom<&str> for KeyboardShortcutInput { type Error = anyhow::Error; fn try_from(key: &str) -> std::result::Result { - const SHIFT_KEY: u16 = 0x10; - const CONTROL_KEY: u16 = 0x11; - const ALT_KEY: u16 = 0x12; - const LEFT_WINDOWS_KEY: u16 = 0x5B; - + // not modifier key + if key.len() == 1 { + let input = build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?); + return Ok(KeyboardShortcutInput(input)); + } // the modifier keys are using the Up keypress variant because the user has already // pressed those keys in order to trigger the feature. - let input = match key { - SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY), - CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY), - ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY), - LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY), - _ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?), - }; - - Ok(KeyboardShortcutInput(input)) + if let Some(numeric_modifier_key) = get_numeric_modifier_key(key) { + let input = build_virtual_key_input(InputKeyPress::Up, numeric_modifier_key); + Ok(KeyboardShortcutInput(input)) + } else { + Err(anyhow!("Unsupported modifier key: {key}")) + } } } @@ -278,7 +271,7 @@ mod tests { #[test] #[serial] fn keyboard_shortcut_conversion_succeeds() { - let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "B"]; + let keyboard_shortcut = ["Control", "Alt", "B"]; let _: Vec = keyboard_shortcut .iter() .map(|s| KeyboardShortcutInput::try_from(*s)) @@ -290,7 +283,19 @@ mod tests { #[serial] #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '1'"] fn keyboard_shortcut_conversion_fails_invalid_key() { - let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "1"]; + let keyboard_shortcut = ["Control", "Alt", "1"]; + let _: Vec = keyboard_shortcut + .iter() + .map(|s| KeyboardShortcutInput::try_from(*s)) + .try_collect() + .unwrap(); + } + + #[test] + #[serial] + #[should_panic(expected = "Unsupported modifier key: Shift")] + fn keyboard_shortcut_conversion_fails_with_shift() { + let keyboard_shortcut = ["Control", "Shift", "B"]; let _: Vec = keyboard_shortcut .iter() .map(|s| KeyboardShortcutInput::try_from(*s)) diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index d518ac29aa4..bffa06d2654 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -188,7 +188,7 @@ describe("SettingsComponent", () => { pinServiceAbstraction.isPinSet.mockResolvedValue(false); policyService.policiesByType$.mockReturnValue(of([null])); desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); - desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); + desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Alt", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); configService.getFeatureFlag$.mockReturnValue(of(false)); }); diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.html b/apps/desktop/src/autofill/components/autotype-shortcut.component.html index 6f73d4006ac..feb1f507c97 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.html +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.html @@ -5,7 +5,7 @@

- {{ "editAutotypeShortcutDescription" | i18n }} + {{ "editAutotypeKeyboardModifiersDescription" | i18n }}

{{ "typeShortcut" | i18n }} diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts index 90aa493c596..ea394274600 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts @@ -30,11 +30,9 @@ describe("AutotypeShortcutComponent", () => { const validShortcuts = [ "Control+A", "Alt+B", - "Shift+C", "Win+D", "control+e", // case insensitive "ALT+F", - "SHIFT+G", "WIN+H", ]; @@ -46,14 +44,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept two modifiers with letter", () => { - const validShortcuts = [ - "Control+Alt+A", - "Control+Shift+B", - "Control+Win+C", - "Alt+Shift+D", - "Alt+Win+E", - "Shift+Win+F", - ]; + const validShortcuts = ["Control+Alt+A", "Control+Win+C", "Alt+Win+D", "Alt+Win+E"]; validShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -63,7 +54,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept modifiers in different orders", () => { - const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"]; + const validShortcuts = ["Alt+Control+A", "Win+Control+B", "Win+Alt+C"]; validShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -88,15 +79,14 @@ describe("AutotypeShortcutComponent", () => { const invalidShortcuts = [ "Control+1", "Alt+2", - "Shift+3", "Win+4", "Control+!", "Alt+@", - "Shift+#", + "Alt+#", "Win+$", "Control+Space", "Alt+Enter", - "Shift+Tab", + "Control+Tab", "Win+Escape", ]; @@ -111,12 +101,10 @@ describe("AutotypeShortcutComponent", () => { const invalidShortcuts = [ "Control", "Alt", - "Shift", "Win", "Control+Alt", - "Control+Shift", - "Alt+Shift", - "Control+Alt+Shift", + "Control+Win", + "Control+Alt+Win", ]; invalidShortcuts.forEach((shortcut) => { @@ -127,7 +115,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject shortcuts with invalid modifier names", () => { - const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"]; + const invalidShortcuts = ["Ctrl+A", "Command+A", "Meta+A", "Cmd+A", "Invalid+A"]; invalidShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -137,7 +125,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject shortcuts with multiple base keys", () => { - const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"]; + const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Win"]; invalidShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -148,11 +136,10 @@ describe("AutotypeShortcutComponent", () => { it("should reject shortcuts with more than two modifiers", () => { const invalidShortcuts = [ - "Control+Alt+Shift+A", + "Control+Alt+Win+A", "Control+Alt+Win+B", - "Control+Shift+Win+C", - "Alt+Shift+Win+D", - "Control+Alt+Shift+Win+E", + "Control+Alt+Win+C", + "Alt+Control+Win+D", ]; invalidShortcuts.forEach((shortcut) => { @@ -221,7 +208,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should handle very long strings", () => { - const longString = "Control+Alt+Shift+Win+A".repeat(100); + const longString = "Control+Alt+Win+A".repeat(100); const control = createControl(longString); const result = validator(control); expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); @@ -230,7 +217,7 @@ describe("AutotypeShortcutComponent", () => { describe("modifier combinations", () => { it("should accept all possible single modifier combinations", () => { - const modifiers = ["Control", "Alt", "Shift", "Win"]; + const modifiers = ["Control", "Alt", "Win"]; modifiers.forEach((modifier) => { const control = createControl(`${modifier}+A`); @@ -240,14 +227,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept all possible two-modifier combinations", () => { - const combinations = [ - "Control+Alt+A", - "Control+Shift+A", - "Control+Win+A", - "Alt+Shift+A", - "Alt+Win+A", - "Shift+Win+A", - ]; + const combinations = ["Control+Alt+A", "Control+Win+A", "Alt+Win+A"]; combinations.forEach((shortcut) => { const control = createControl(shortcut); @@ -257,12 +237,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject all three-modifier combinations", () => { - const combinations = [ - "Control+Alt+Shift+A", - "Control+Alt+Win+A", - "Control+Shift+Win+A", - "Alt+Shift+Win+A", - ]; + const combinations = ["Control+Alt+Win+A", "Alt+Control+Win+A", "Win+Alt+Control+A"]; combinations.forEach((shortcut) => { const control = createControl(shortcut); @@ -270,12 +245,6 @@ describe("AutotypeShortcutComponent", () => { expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); }); }); - - it("should reject all four modifiers combination", () => { - const control = createControl("Control+Alt+Shift+Win+A"); - const result = validator(control); - expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); - }); }); }); }); diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts index 3c82d8297a1..4e1a0c2108c 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -77,25 +77,31 @@ export class AutotypeShortcutComponent { } } + // private buildShortcutFromEvent(event: KeyboardEvent): string | null { const hasCtrl = event.ctrlKey; const hasAlt = event.altKey; const hasShift = event.shiftKey; - const hasMeta = event.metaKey; // Windows key on Windows, Command on macOS + const hasSuper = event.metaKey; // Windows key on Windows, Command on macOS - // Require at least one modifier (Control, Alt, Shift, or Super) - if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) { + // Require at least one valid modifier (Control, Alt, Super) + if (!hasCtrl && !hasAlt && !hasSuper) { return null; } const key = event.key; - // Ignore pure modifier keys themselves - if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") { + // disallow pure modifier keys themselves + if (key === "Control" || key === "Alt" || key === "Meta") { return null; } - // Accept a single alphabetical letter as the base key + // disallow shift modifier + if (hasShift) { + return null; + } + + // require a single alphabetical letter as the base key const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key); if (!isAlphabetical) { return null; @@ -108,10 +114,7 @@ export class AutotypeShortcutComponent { if (hasAlt) { parts.push("Alt"); } - if (hasShift) { - parts.push("Shift"); - } - if (hasMeta) { + if (hasSuper) { parts.push("Super"); } parts.push(key.toUpperCase()); @@ -129,10 +132,9 @@ export class AutotypeShortcutComponent { } // Must include exactly 1-2 modifiers and end with a single letter - // Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q + // Valid examples: Ctrl+A, Alt+B, Ctrl+Alt+X, Alt+Control+Q, Win+B, Ctrl+Win+A // Allow modifiers in any order, but only 1-2 modifiers total - const pattern = - /^(?=.*\b(Control|Alt|Shift|Win)\b)(?:Control\+|Alt\+|Shift\+|Win\+){1,2}[A-Z]$/i; + const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i; return pattern.test(value) ? null : { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } }; diff --git a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts index b26be92585e..8b241ade032 100644 --- a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts +++ b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts @@ -1,4 +1,14 @@ -import { defaultWindowsAutotypeKeyboardShortcut } from "../services/desktop-autotype.service"; +/** + Electron's representation of modifier keys + +*/ +export const CONTROL_KEY_STR = "Control"; +export const ALT_KEY_STR = "Alt"; +export const SUPER_KEY_STR = "Super"; + +export const VALID_SHORTCUT_MODIFIER_KEYS: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, SUPER_KEY_STR]; + +export const DEFAULT_KEYBOARD_SHORTCUT: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, "B"]; /* This class provides the following: @@ -13,7 +23,7 @@ export class AutotypeKeyboardShortcut { private autotypeKeyboardShortcut: string[]; constructor() { - this.autotypeKeyboardShortcut = defaultWindowsAutotypeKeyboardShortcut; + this.autotypeKeyboardShortcut = DEFAULT_KEYBOARD_SHORTCUT; } /* @@ -51,14 +61,16 @@ export class AutotypeKeyboardShortcut { This private function validates the strArray input to make sure the array contains valid, currently accepted shortcut keys for Windows. - Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z - Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z (not yet supported) + Valid shortcut keys: Control, Alt, Super, letters A - Z + Platform specifics: + - On Windows, Super maps to the Windows key. + - On MacOS, Super maps to the Command key. + - On MacOS, Alt maps to the Option key. See Electron keyboard shorcut docs for more info: https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts */ #keyboardShortcutIsValid(strArray: string[]) { - const VALID_SHORTCUT_CONTROL_KEYS: string[] = ["Control", "Alt", "Super", "Shift"]; const UNICODE_LOWER_BOUND = 65; // unicode 'A' const UNICODE_UPPER_BOUND = 90; // unicode 'Z' const MIN_LENGTH: number = 2; @@ -77,7 +89,7 @@ export class AutotypeKeyboardShortcut { // Ensure strArray is all modifier keys, and that the last key is a letter for (let i = 0; i < strArray.length; i++) { if (i < strArray.length - 1) { - if (!VALID_SHORTCUT_CONTROL_KEYS.includes(strArray[i])) { + if (!VALID_SHORTCUT_MODIFIER_KEYS.includes(strArray[i])) { return false; } } else { diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 46fec662d7a..d108577567d 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -33,11 +33,10 @@ import { UserId } from "@bitwarden/user-core"; import { AutotypeConfig } from "../models/autotype-config"; import { AutotypeVaultData } from "../models/autotype-vault-data"; +import { DEFAULT_KEYBOARD_SHORTCUT } from "../models/main-autotype-keyboard-shortcut"; import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; -export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"]; - export const AUTOTYPE_ENABLED = new KeyDefinition( AUTOTYPE_SETTINGS_DISK, "autotypeEnabled", @@ -72,10 +71,9 @@ export class DesktopAutotypeService implements OnDestroy { private readonly isPremiumAccount$: Observable; // The enabled/disabled state from the user settings menu - autotypeEnabledUserSetting$: Observable; + autotypeEnabledUserSetting$: Observable = of(false); - // The keyboard shortcut from the user settings menu - autotypeKeyboardShortcut$: Observable = of(defaultWindowsAutotypeKeyboardShortcut); + autotypeKeyboardShortcut$: Observable = of(DEFAULT_KEYBOARD_SHORTCUT); private destroy$ = new Subject(); @@ -106,7 +104,7 @@ export class DesktopAutotypeService implements OnDestroy { ); this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( - map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), + map((shortcut) => shortcut ?? DEFAULT_KEYBOARD_SHORTCUT), takeUntil(this.destroy$), ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index c47817f3ee4..b00233457ec 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" From 5832065e96c1396e170b397e5a1fa6a09dcbf67b Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:25:10 -0500 Subject: [PATCH 046/101] Revert "[PM-30319] [BLOCKER] phish cache freeze (#18157)" (#18245) This reverts commit fcc2844a16d7cfb9c6b2ad263156b9d7e7cf46a1. --- .../browser/src/background/main.background.ts | 34 +- .../src/background/runtime.background.ts | 9 - .../phishing-detection/phishing-resources.ts | 6 +- .../services/phishing-data.service.spec.ts | 7 +- .../services/phishing-data.service.ts | 454 ++++-------------- .../phishing-detection.service.spec.ts | 6 +- .../services/phishing-detection.service.ts | 108 +---- ...hishing-detection-settings.service.spec.ts | 31 +- .../phishing-detection-settings.service.ts | 12 +- 9 files changed, 124 insertions(+), 543 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 09ae9deb8f1..b9b41943b04 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -506,7 +506,6 @@ export default class MainBackground { // DIRT private phishingDataService: PhishingDataService; private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction; - private phishingDetectionCleanup: (() => void) | null = null; constructor() { const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => @@ -1516,12 +1515,7 @@ export default class MainBackground { this.stateProvider, ); - // Call cleanup from previous initialization if it exists (service worker restart scenario) - if (this.phishingDetectionCleanup) { - this.phishingDetectionCleanup(); - } - - this.phishingDetectionCleanup = PhishingDetectionService.initialize( + PhishingDetectionService.initialize( this.logService, this.phishingDataService, this.phishingDetectionSettingsService, @@ -1680,32 +1674,6 @@ export default class MainBackground { } } - /** - * Triggers a phishing cache update in the background. - * Called on extension install/update to pre-populate the cache - * so it's ready when a premium user logs in. - * - * Creates a temporary subscription to ensure the update executes even if - * there are no other subscribers (install/update scenario). The subscription - * is automatically cleaned up after the update completes or errors. - */ - triggerPhishingCacheUpdate(): void { - // Create a temporary subscription to ensure the update executes - // since update$ uses shareReplay with refCount: true, which requires at least one subscriber - const tempSub = this.phishingDataService.update$.subscribe({ - next: () => { - this.logService.debug("[MainBackground] Phishing cache pre-population completed"); - tempSub.unsubscribe(); - }, - error: (err: unknown) => { - this.logService.error("[MainBackground] Phishing cache pre-population failed", err); - tempSub.unsubscribe(); - }, - }); - // Trigger the update after subscription is created - this.phishingDataService.triggerUpdateIfNeeded(); - } - /** * Switch accounts to indicated userId -- null is no active user */ diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index e4d3c428802..eba6b01fe90 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -433,15 +433,6 @@ export default class RuntimeBackground { void this.autofillService.loadAutofillScriptsOnInstall(); if (this.onInstalledReason != null) { - // Pre-populate phishing cache on install/update so it's ready when premium user logs in - // This runs in background and doesn't block the user - if (this.onInstalledReason === "install" || this.onInstalledReason === "update") { - this.logService.debug( - `[RuntimeBackground] Extension ${this.onInstalledReason}: triggering phishing cache pre-population`, - ); - this.main.triggerPhishingCacheUpdate(); - } - if ( this.onInstalledReason === "install" && !(await firstValueFrom(this.browserInitialInstallService.extensionInstalled$)) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 4cd155c8ae3..262d6cf833b 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -18,7 +18,8 @@ export const PHISHING_RESOURCES: Record { expect(result!.applicationVersion).toBe("2.0.0"); }); - it("returns null if checksum matches (no update needed)", async () => { + it("only updates timestamp if checksum matches", async () => { const prev: PhishingData = { webAddresses: ["a.com"], timestamp: Date.now() - 60000, @@ -122,8 +122,9 @@ describe("PhishingDataService", () => { }; fetchChecksumSpy.mockResolvedValue("abc"); const result = await service.getNextWebAddresses(prev); - // When checksum matches, return null to signal "skip state update" - expect(result).toBeNull(); + expect(result!.webAddresses).toEqual(prev.webAddresses); + expect(result!.checksum).toBe("abc"); + expect(result!.timestamp).not.toBe(prev.timestamp); }); it("patches daily domains if cache is fresh", async () => { diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 6f5e6dc63f3..21fe74f1873 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -1,17 +1,13 @@ import { catchError, - distinctUntilChanged, EMPTY, - filter, - finalize, first, firstValueFrom, - from, - of, + map, retry, - shareReplay, + share, + startWith, Subject, - Subscription, switchMap, tap, timer, @@ -22,12 +18,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { LogService } from "@bitwarden/logging"; -import { - GlobalState, - GlobalStateProvider, - KeyDefinition, - PHISHING_DETECTION_DISK, -} from "@bitwarden/state"; +import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state"; import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; @@ -47,31 +38,70 @@ export const PHISHING_DOMAINS_KEY = new KeyDefinition( PHISHING_DETECTION_DISK, "phishingDomains", { - deserializer: (value: PhishingData) => { - return value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }; - }, + deserializer: (value: PhishingData) => + value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }, }, ); /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { - // Static tracking to prevent interval accumulation across service instances (reload scenario) - private static _intervalSubscription: Subscription | null = null; - private _testWebAddresses = this.getTestWebAddresses(); - private _cachedPhishingDataStateInstance: GlobalState | null = null; + private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); + private _webAddresses$ = this._cachedState.state$.pipe( + map( + (state) => + new Set( + (state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat( + this._testWebAddresses, + "phishing.testcategory.com", // Included for QA to test in prod + ), + ), + ), + ); - /** - * Lazy getter for cached phishing data state. Only accesses storage when phishing detection is actually used. - * This prevents blocking service worker initialization on extension reload for non-premium users. - */ - private get _cachedPhishingDataState() { - if (this._cachedPhishingDataStateInstance === null) { - this.logService.debug("[PhishingDataService] Lazy-loading state from storage (first access)"); - this._cachedPhishingDataStateInstance = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); - } - return this._cachedPhishingDataStateInstance; - } + // How often are new web addresses added to the remote? + readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + + private _triggerUpdate$ = new Subject(); + update$ = this._triggerUpdate$.pipe( + startWith(undefined), // Always emit once + tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), + switchMap(() => + this._cachedState.state$.pipe( + first(), // Only take the first value to avoid an infinite loop when updating the cache below + switchMap(async (cachedState) => { + const next = await this.getNextWebAddresses(cachedState); + if (next) { + await this._cachedState.update(() => next); + this.logService.info(`[PhishingDataService] cache updated`); + } + }), + retry({ + count: 3, + delay: (err, count) => { + this.logService.error( + `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, + err, + ); + return timer(5 * 60 * 1000); // 5 minutes + }, + resetOnSuccess: true, + }), + catchError( + ( + err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, + ) => { + this.logService.error( + "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", + err, + ); + return EMPTY; + }, + ), + ), + ), + share(), + ); constructor( private apiService: ApiService, @@ -84,182 +114,12 @@ export class PhishingDataService { this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); - - // Clean up previous interval if it exists (prevents accumulation on service recreation) - if (PhishingDataService._intervalSubscription) { - PhishingDataService._intervalSubscription.unsubscribe(); - PhishingDataService._intervalSubscription = null; - } - // Store interval subscription statically to prevent accumulation on reload - PhishingDataService._intervalSubscription = this.taskSchedulerService.setInterval( + this.taskSchedulerService.setInterval( ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); } - // In-memory cache to avoid expensive Set rebuilds and state rewrites - private _cachedWebAddressesSet: Set | null = null; - private _cachedSetChecksum: string = ""; - private _lastCheckTime: number = 0; // Track check time in memory, not state - - // Lazy observable: only subscribes to state$ when actually needed (first URL check) - // This prevents blocking service worker initialization on extension reload - // Using a getter with caching to defer access to _cachedPhishingDataState until actually subscribed - private _webAddresses$Instance: ReturnType | null = null; - private get _webAddresses$() { - if (this._webAddresses$Instance === null) { - this._webAddresses$Instance = this.createWebAddresses$(); - } - return this._webAddresses$Instance; - } - - private createWebAddresses$() { - return this._cachedPhishingDataState.state$.pipe( - // Only rebuild Set when checksum changes (actual data change) - distinctUntilChanged((prev, curr) => prev?.checksum === curr?.checksum), - switchMap((state) => { - // Return cached Set if checksum matches - if (this._cachedWebAddressesSet && state?.checksum === this._cachedSetChecksum) { - this.logService.debug( - `[PhishingDataService] Using cached Set (${this._cachedWebAddressesSet.size} entries, checksum: ${state?.checksum.substring(0, 8)}...)`, - ); - return of(this._cachedWebAddressesSet); - } - // Build Set in chunks to avoid blocking UI - this.logService.debug( - `[PhishingDataService] Building Set from ${state?.webAddresses?.length ?? 0} entries`, - ); - return from(this.buildSetInChunks(state?.webAddresses ?? [], state?.checksum ?? "")); - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - } - - // How often are new web addresses added to the remote? - readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours - - // Minimum time between updates when triggered by account switch (5 minutes) - private readonly MIN_UPDATE_INTERVAL = 5 * 60 * 1000; - - private _triggerUpdate$ = new Subject(); - private _updateInProgress = false; - - /** - * Observable that handles phishing data updates. - * - * Updates are triggered explicitly via triggerUpdateIfNeeded() or the 24-hour scheduler. - * The observable includes safeguards to prevent redundant updates: - * - Skips if an update is already in progress - * - Skips if cache was updated within MIN_UPDATE_INTERVAL (5 min) - * - * Lazy getter with caching: Only accesses _cachedPhishingDataState when actually subscribed to prevent storage read on reload. - */ - private _update$Instance: ReturnType | null = null; - get update$() { - if (this._update$Instance === null) { - this._update$Instance = this.createUpdate$(); - } - return this._update$Instance; - } - - private createUpdate$() { - return this._triggerUpdate$.pipe( - // Don't use startWith - initial update is handled by triggerUpdateIfNeeded() - filter(() => { - if (this._updateInProgress) { - this.logService.debug("[PhishingDataService] Update already in progress, skipping"); - return false; - } - return true; - }), - tap(() => { - this._updateInProgress = true; - }), - switchMap(async () => { - // Get current state directly without subscribing to state$ observable - // This avoids creating a subscription that stays active - const cachedState = await firstValueFrom( - this._cachedPhishingDataState.state$.pipe(first()), - ); - - // Early exit if we checked recently (using in-memory tracking) - const timeSinceLastCheck = Date.now() - this._lastCheckTime; - if (timeSinceLastCheck < this.MIN_UPDATE_INTERVAL) { - this.logService.debug( - `[PhishingDataService] Checked ${Math.round(timeSinceLastCheck / 1000)}s ago, skipping`, - ); - return; - } - - // Update last check time in memory (not state - avoids expensive write) - this._lastCheckTime = Date.now(); - - try { - const result = await this.getNextWebAddresses(cachedState); - - // result is null when checksum matched - skip state update entirely - if (result === null) { - this.logService.debug("[PhishingDataService] Checksum matched, skipping state update"); - return; - } - - if (result) { - // Yield to event loop before state update - await new Promise((resolve) => setTimeout(resolve, 0)); - await this._cachedPhishingDataState.update(() => result); - this.logService.info( - `[PhishingDataService] State updated with ${result.webAddresses?.length ?? 0} entries`, - ); - } - } catch (err) { - this.logService.error("[PhishingDataService] Unable to update web addresses.", err); - // Retry logic removed - let the 24-hour scheduler handle retries - throw err; - } - }), - retry({ - count: 3, - delay: (err, count) => { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, - err, - ); - return timer(5 * 60 * 1000); // 5 minutes - }, - resetOnSuccess: true, - }), - catchError( - ( - err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, - ) => { - this.logService.error( - "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", - err, - ); - return EMPTY; - }, - ), - // Use finalize() to ensure _updateInProgress is reset on success, error, OR completion - // Per ADR: "Use finalize() operator to ensure cleanup code always runs" - finalize(() => { - this._updateInProgress = false; - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - } - - /** - * Triggers an update if the cache is stale or empty. - * Should be called when phishing detection is enabled for an account or on install/update. - * - * The lazy loading of _cachedPhishingDataState ensures that storage is only accessed - * when the update$ observable chain actually executes (i.e., when there are subscribers). - * If there are no subscribers, the chain doesn't execute and no storage access occurs. - */ - triggerUpdateIfNeeded(): void { - this._triggerUpdate$.next(); - } - /** * Checks if the given URL is a known phishing web address * @@ -267,16 +127,13 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - // Lazy load: Only now do we subscribe to _webAddresses$ and trigger storage read + Set build - // This ensures we don't block service worker initialization on extension reload - this.logService.debug(`[PhishingDataService] Checking URL: ${url.href}`); + // Use domain (hostname) matching for domain resources, and link matching for links resources const entries = await firstValueFrom(this._webAddresses$); const resource = getPhishingResources(this.resourceType); if (resource && resource.match) { for (const entry of entries) { if (resource.match(url, entry)) { - this.logService.info(`[PhishingDataService] Match: ${url.href} matched entry: ${entry}`); return true; } } @@ -287,72 +144,44 @@ export class PhishingDataService { return entries.has(url.hostname); } - /** - * Determines if the phishing data needs to be updated and fetches new data if necessary. - * - * The CHECKSUM is an MD5 hash of the phishing list file, hosted at: - * For full url see: clients/apps/browser/src/dirt/phishing-detection/phishing-resources.ts - * - Links: https://raw.githubusercontent.com/Phishing-Database/checksums/.../phishing-links-ACTIVE.txt.md5 - * - Domains: https://raw.githubusercontent.com/Phishing-Database/checksums/.../phishing-domains-ACTIVE.txt.md5 - * - * PURPOSE: The checksum allows us to quickly check if the list has changed without - * downloading the entire file (~63MB uncompressed). If checksums match, data is identical. - * - * FLOW: - * 1. Fetch remote checksum (~62 bytes) - fast - * 2. Compare to local cached checksum - * 3. If match: return null (skip expensive state update) - * 4. If different: fetch new data and update state - * - * @returns PhishingData if data changed, null if checksum matched (no update needed) - */ async getNextWebAddresses(prev: PhishingData | null): Promise { prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }; const timestamp = Date.now(); const prevAge = timestamp - prev.timestamp; - - this.logService.debug( - `[PhishingDataService] Cache: ${prev.webAddresses?.length ?? 0} entries, age ${Math.round(prevAge / 1000 / 60)}min`, - ); + this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - // STEP 1: Fetch the remote checksum (tiny file, ~32 bytes) + // If checksum matches, return existing data with new timestamp & version const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - - // STEP 2: Compare checksums if (remoteChecksum && prev.checksum === remoteChecksum) { - this.logService.debug("[PhishingDataService] Checksum match, no update needed"); - return null; // Signal to skip state update - no UI blocking! - } - - // STEP 3: Checksum different - data needs to be updated - this.logService.info("[PhishingDataService] Checksum mismatch, fetching new data"); - - // Approach 1: Fetch only today's new entries (if cache is less than 24h old) - const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; - if ( - isOneDayOldMax && - applicationVersion === prev.applicationVersion && - (prev.webAddresses?.length ?? 0) > 0 - ) { - const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl; - const dailyWebAddresses = await this.fetchPhishingWebAddresses(webAddressesTodayUrl); this.logService.info( - `[PhishingDataService] Daily update: +${dailyWebAddresses.length} entries`, + `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, + ); + return { ...prev, timestamp, applicationVersion }; + } + // Checksum is different, data needs to be updated. + + // Approach 1: Fetch only new web addresses and append + const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; + if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { + const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl; + const dailyWebAddresses: string[] = + await this.fetchPhishingWebAddresses(webAddressesTodayUrl); + this.logService.info( + `[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`, ); return { - webAddresses: (prev.webAddresses ?? []).concat(dailyWebAddresses), + webAddresses: prev.webAddresses.concat(dailyWebAddresses), checksum: remoteChecksum, timestamp, applicationVersion, }; } - // Approach 2: Fetch entire list (cache is stale or empty) + // Approach 2: Fetch all web addresses const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl); - this.logService.info(`[PhishingDataService] Full update: ${remoteWebAddresses.length} entries`); return { webAddresses: remoteWebAddresses, timestamp, @@ -361,136 +190,23 @@ export class PhishingDataService { }; } - /** - * Fetches the MD5 checksum of the phishing list from GitHub. - * The checksum file is tiny (~32 bytes) and fast to fetch. - * Used to detect if the phishing list has changed without downloading the full list. - */ private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { const checksumUrl = getPhishingResources(type)!.checksumUrl; - this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`); const response = await this.apiService.nativeFetch(new Request(checksumUrl)); if (!response.ok) { throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); } - const checksum = await response.text(); - return checksum.trim(); // MD5 checksums are 32 hex characters + return response.text(); } - /** - * Fetches phishing web addresses from the given URL. - * Uses streaming to avoid loading the entire file into memory at once, - * which can cause Firefox to freeze due to memory pressure. - */ - private async fetchPhishingWebAddresses(url: string): Promise { + private async fetchPhishingWebAddresses(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { throw new Error(`[PhishingDataService] Failed to fetch web addresses: ${response.status}`); } - // Stream the response to avoid loading entire file into memory at once - // This prevents Firefox from freezing on large phishing lists (~63MB uncompressed) - const reader = response.body?.getReader(); - if (!reader) { - // Fallback for environments without streaming support - this.logService.warning( - "[PhishingDataService] Streaming not available, falling back to full load", - ); - const text = await response.text(); - return text - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l.length > 0); - } - - const decoder = new TextDecoder(); - const addresses: string[] = []; - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines from buffer - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() || ""; // Keep incomplete last line in buffer - - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); - if (trimmed.length > 0) { - addresses.push(trimmed); - } - } - // Yield after processing each network chunk to keep service worker responsive - // This allows popup messages to be handled between chunks - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - // Process any remaining buffer content - const remaining = buffer.trim(); - if (remaining.length > 0) { - addresses.push(remaining); - } - } finally { - // Ensure reader is released even if an error occurs - reader.releaseLock(); - } - - this.logService.debug(`[PhishingDataService] Streamed ${addresses.length} addresses`); - return addresses; - } - - /** - * Builds a Set from an array of web addresses in chunks to avoid blocking the UI. - * Yields to the event loop every CHUNK_SIZE entries, keeping the UI responsive - * even when processing 700K+ entries. - * - * @param addresses Array of web addresses to add to the Set - * @param checksum The checksum to associate with this cached Set - * @returns Promise that resolves to the built Set - */ - private async buildSetInChunks(addresses: string[], checksum: string): Promise> { - const CHUNK_SIZE = 50000; // Process 50K entries per chunk (fast, fewer iterations) - const startTime = Date.now(); - const set = new Set(); - - this.logService.debug(`[PhishingDataService] Building Set (${addresses.length} entries)`); - - for (let i = 0; i < addresses.length; i += CHUNK_SIZE) { - const chunk = addresses.slice(i, Math.min(i + CHUNK_SIZE, addresses.length)); - for (const addr of chunk) { - if (addr) { - // Skip empty strings - set.add(addr); - } - } - - // Yield to event loop after each chunk - if (i + CHUNK_SIZE < addresses.length) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - - // Add test addresses - this._testWebAddresses.forEach((addr) => set.add(addr)); - set.add("phishing.testcategory.com"); // For QA testing - - // Cache for future use - this._cachedWebAddressesSet = set; - this._cachedSetChecksum = checksum; - - const buildTime = Date.now() - startTime; - this.logService.debug( - `[PhishingDataService] Set built: ${set.size} entries in ${buildTime}ms (checksum: ${checksum.substring(0, 8)}...)`, - ); - - return set; + return response.text().then((text) => text.split("\n")); } private getTestWebAddresses() { @@ -502,7 +218,7 @@ export class PhishingDataService { const webAddresses = devFlagValue("testPhishingUrls") as unknown[]; if (webAddresses && webAddresses instanceof Array) { this.logService.debug( - "[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", + "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", webAddresses, ); return webAddresses as string[]; diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts index cebdd4c9c73..06a37f12faa 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { EMPTY, Observable, of } from "rxjs"; +import { Observable, of } from "rxjs"; import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -16,9 +16,7 @@ describe("PhishingDetectionService", () => { beforeEach(() => { logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; - phishingDataService = mock({ - update$: EMPTY, - }); + phishingDataService = mock(); messageListener = mock({ messages$(_commandDefinition) { return new Observable(); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 744540f9ec8..d90e872eef8 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,14 +1,11 @@ import { concatMap, - delay, distinctUntilChanged, EMPTY, filter, map, merge, - of, Subject, - Subscription, switchMap, tap, } from "rxjs"; @@ -46,8 +43,6 @@ export class PhishingDetectionService { private static _tabUpdated$ = new Subject(); private static _ignoredHostnames = new Set(); private static _didInit = false; - private static _triggerUpdateSub: Subscription | null = null; - private static _boundTabHandler: ((...args: readonly unknown[]) => unknown) | null = null; static initialize( logService: LogService, @@ -55,34 +50,18 @@ export class PhishingDetectionService { phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction, messageListener: MessageListener, ) { - // If already initialized, clean up first to prevent memory leaks on service worker restart if (this._didInit) { - logService.debug( - "[PhishingDetectionService] Initialize already called. Cleaning up previous instance first.", - ); - // Clean up previous state - if (this._triggerUpdateSub) { - this._triggerUpdateSub.unsubscribe(); - this._triggerUpdateSub = null; - } - if (this._boundTabHandler) { - BrowserApi.removeListener(chrome.tabs.onUpdated, this._boundTabHandler); - this._boundTabHandler = null; - } - // Clear accumulated state - this._ignoredHostnames.clear(); - // Reset flag to allow re-initialization - this._didInit = false; + logService.debug("[PhishingDetectionService] Initialize already called. Aborting."); + return; } - this._boundTabHandler = this._handleTabUpdated.bind(this) as ( - ...args: readonly unknown[] - ) => unknown; - BrowserApi.addListener(chrome.tabs.onUpdated, this._boundTabHandler); + logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites..."); + + BrowserApi.addListener(chrome.tabs.onUpdated, this._handleTabUpdated.bind(this)); const onContinueCommand$ = messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND).pipe( tap((message) => - logService.debug(`[PhishingDetectionService] User selected continue for ${message.url}`), + logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), concatMap(async (message) => { const url = new URL(message.url); @@ -108,9 +87,7 @@ export class PhishingDetectionService { prev.tabId === curr.tabId && prev.ignored === curr.ignored, ), - tap((event) => - logService.debug(`[PhishingDetectionService] Processing navigation event:`, event), - ), + tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), concatMap(async ({ tabId, url, ignored }) => { if (ignored) { // The next time this host is visited, block again @@ -136,58 +113,23 @@ export class PhishingDetectionService { const phishingDetectionActive$ = phishingDetectionSettingsService.on$; - // CRITICAL: Only subscribe to update$ if phishing detection is available - // This prevents storage access for non-premium users on extension reload - // The subscription is created lazily when phishing detection becomes active - let updateSub: Subscription | null = null; - const initSub = phishingDetectionActive$ .pipe( distinctUntilChanged(), switchMap((activeUserHasAccess) => { - // Clean up previous trigger subscription if it exists - // This prevents memory leaks when account access changes (switch, lock/unlock) - if (this._triggerUpdateSub) { - this._triggerUpdateSub.unsubscribe(); - this._triggerUpdateSub = null; - } - if (!activeUserHasAccess) { logService.debug( "[PhishingDetectionService] User does not have access to phishing detection service.", ); - // Unsubscribe from update$ if user loses access (e.g., account switch to non-premium) - if (updateSub) { - updateSub.unsubscribe(); - updateSub = null; - } return EMPTY; } else { logService.debug("[PhishingDetectionService] Enabling phishing detection service"); - // Lazy subscription: Only subscribe to update$ when phishing detection becomes active - // This prevents storage access for non-premium users on extension reload - if (!updateSub) { - updateSub = phishingDataService.update$.subscribe({ - next: () => { - logService.debug("[PhishingDetectionService] Update completed"); - }, - error: (err: unknown) => { - logService.error("[PhishingDetectionService] Update error", err); - }, - complete: () => { - logService.debug("[PhishingDetectionService] Update subscription completed"); - }, - }); - } - // Trigger cache update asynchronously using RxJS delay(0) - // This defers to the next event loop tick, preventing UI blocking during account switch - // CRITICAL: Store subscription to prevent memory leaks on account switches - this._triggerUpdateSub = of(null) - .pipe(delay(0)) - .subscribe(() => phishingDataService.triggerUpdateIfNeeded()); - // update$ removed from merge - popup no longer blocks waiting for update - // The actual update runs via updateSub above - return merge(onContinueCommand$, onTabUpdated$, onCancelCommand$); + return merge( + phishingDataService.update$, + onContinueCommand$, + onTabUpdated$, + onCancelCommand$, + ); } }), ) @@ -195,26 +137,16 @@ export class PhishingDetectionService { this._didInit = true; return () => { - logService.debug("[PhishingDetectionService] Cleanup function called"); - if (updateSub) { - updateSub.unsubscribe(); - updateSub = null; - } initSub.unsubscribe(); - // Clean up trigger subscription to prevent memory leaks - if (this._triggerUpdateSub) { - this._triggerUpdateSub.unsubscribe(); - this._triggerUpdateSub = null; - } this._didInit = false; - if (this._boundTabHandler) { - BrowserApi.removeListener(chrome.tabs.onUpdated, this._boundTabHandler); - this._boundTabHandler = null; - } - - // Clear accumulated state to prevent memory leaks - this._ignoredHostnames.clear(); + // Manually type cast to satisfy the listener signature due to the mixture + // of static and instance methods in this class. To be fixed when refactoring + // this class to be instance-based while providing a singleton instance in usage + BrowserApi.removeListener( + chrome.tabs.onUpdated, + PhishingDetectionService._handleTabUpdated as (...args: readonly unknown[]) => unknown, + ); }; } diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts index f9cb93d05b8..e6363b490cb 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts @@ -1,6 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, Subject } from "rxjs"; -import { filter } from "rxjs/operators"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -98,32 +97,19 @@ describe("PhishingDetectionSettingsService", () => { describe("enabled$", () => { it("should default to true if an account is logged in", async () => { activeAccountSubject.next(account); - featureFlagSubject.next(true); - premiumStatusSubject.next(true); - organizationsSubject.next([]); const result = await firstValueFrom(service.enabled$); expect(result).toBe(true); }); it("should return the stored value", async () => { activeAccountSubject.next(account); - featureFlagSubject.next(true); - premiumStatusSubject.next(true); - organizationsSubject.next([]); - - // Wait for initial emission (startWith(true)) - await firstValueFrom(service.enabled$); await service.setEnabled(mockUserId, false); - // Wait for the next emission after state update - const resultDisabled = await firstValueFrom( - service.enabled$.pipe(filter((v) => v === false)), - ); + const resultDisabled = await firstValueFrom(service.enabled$); expect(resultDisabled).toBe(false); await service.setEnabled(mockUserId, true); - // Wait for the next emission after state update - const resultEnabled = await firstValueFrom(service.enabled$.pipe(filter((v) => v === true))); + const resultEnabled = await firstValueFrom(service.enabled$); expect(resultEnabled).toBe(true); }); }); @@ -131,21 +117,12 @@ describe("PhishingDetectionSettingsService", () => { describe("setEnabled", () => { it("should update the stored value", async () => { activeAccountSubject.next(account); - featureFlagSubject.next(true); - premiumStatusSubject.next(true); - organizationsSubject.next([]); - - // Wait for initial emission (startWith(true)) - await firstValueFrom(service.enabled$); - await service.setEnabled(mockUserId, false); - // Wait for the next emission after state update - let result = await firstValueFrom(service.enabled$.pipe(filter((v) => v === false))); + let result = await firstValueFrom(service.enabled$); expect(result).toBe(false); await service.setEnabled(mockUserId, true); - // Wait for the next emission after state update - result = await firstValueFrom(service.enabled$.pipe(filter((v) => v === true))); + result = await firstValueFrom(service.enabled$); expect(result).toBe(true); }); }); diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts index 9927e099f24..e30592b2f68 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts @@ -1,5 +1,5 @@ import { combineLatest, Observable, of, switchMap } from "rxjs"; -import { catchError, distinctUntilChanged, map, shareReplay, startWith } from "rxjs/operators"; +import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -18,9 +18,7 @@ const ENABLE_PHISHING_DETECTION = new UserKeyDefinition( PHISHING_DETECTION_DISK, "enablePhishingDetection", { - deserializer: (value: boolean) => { - return value ?? true; - }, // Default: enabled + deserializer: (value: boolean) => value ?? true, // Default: enabled clearOn: [], }, ); @@ -99,11 +97,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin if (!account) { return of(false); } - return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id).pipe( - startWith(true), // Default: enabled (matches deserializer default) - map((enabled) => enabled ?? true), - ); + return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id); }), + map((enabled) => enabled ?? true), ); } From dfa633f7abb09c9c36ad4dc37ffce02fec905552 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 7 Jan 2026 13:54:50 -0600 Subject: [PATCH 047/101] PM-26917 Added a document on how to create an integration (#18248) --- .../services/README.md | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md new file mode 100644 index 00000000000..1796b0db071 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md @@ -0,0 +1,358 @@ +# Adding a New Integration Configuration and Template + +This guide explains how to add a new integration type (e.g., Datadog, Splunk HEC) to the organization integrations system. + +## Step 1: Define the Configuration Class + +Create a new configuration class that implements `OrgIntegrationConfiguration`: + +```typescript +export class MyServiceConfiguration implements OrgIntegrationConfiguration { + // Required: Specify which service this configuration is for + bw_serviceName: OrganizationIntegrationServiceName; + + // Add service-specific properties (e.g., uri, apiKey, token) + uri: string; + apiKey: string; + + constructor(uri: string, apiKey: string, bw_serviceName: OrganizationIntegrationServiceName) { + this.uri = uri; + this.apiKey = apiKey; + this.bw_serviceName = bw_serviceName; + } + + // Required: Serialize configuration to JSON string for API transmission + // Property names should match PascalCase for backend compatibility + // Example: "Uri", "ApiKey" - the backend expects PascalCase keys + toString(): string { + return JSON.stringify({ + Uri: this.uri, + ApiKey: this.apiKey, + bw_serviceName: this.bw_serviceName, + }); + } +} +``` + +**Required Interface Properties:** + +- `bw_serviceName: OrganizationIntegrationServiceName` - Identifies the external service +- `toString(): string` - Serializes configuration for API storage + +## Step 2: Define the Template Class + +Create a template class that implements `OrgIntegrationTemplate`: + +```typescript +export class MyServiceTemplate implements OrgIntegrationTemplate { + // Required: Specify which service this template is for + bw_serviceName: OrganizationIntegrationServiceName; + + // Add template-specific properties with placeholders (e.g., #CipherId#, #UserEmail#) + // These placeholders will be replaced with actual values at runtime + + constructor(service: OrganizationIntegrationServiceName) { + this.bw_serviceName = service; + } + + // Required: Serialize template to JSON string + // Define the structure of data that will be sent to the external service + toString(): string { + return JSON.stringify({ + bw_serviceName: this.bw_serviceName, + event: { + type: "#Type#", + userId: "#UserId#", + // ... other placeholders + }, + }); + } +} +``` + +**Required Interface Properties:** + +- `bw_serviceName: OrganizationIntegrationServiceName` - Identifies the external service +- `toString(): string` - Serializes template structure with placeholders + +## Step 3: Update OrganizationIntegrationType + +Add your new integration type to the enum: + +```typescript +export const OrganizationIntegrationType = Object.freeze({ + // ... existing types + MyService: 7, +} as const); +``` + +## Step 4: Extend OrgIntegrationBuilder + +The `OrgIntegrationBuilder` is the central factory for creating and deserializing integration configurations and templates. +It provides a consistent API for the `OrganizationIntegrationService` to work with different integration types. + +Add four methods to `OrgIntegrationBuilder`: + +### 4a. Add a static factory method for configuration: + +```typescript +static buildMyServiceConfiguration( + uri: string, + apiKey: string, + bw_serviceName: OrganizationIntegrationServiceName +): OrgIntegrationConfiguration { + return new MyServiceConfiguration(uri, apiKey, bw_serviceName); +} +``` + +### 4b. Add a static factory method for template: + +```typescript +static buildMyServiceTemplate( + bw_serviceName: OrganizationIntegrationServiceName +): OrgIntegrationTemplate { + return new MyServiceTemplate(bw_serviceName); +} +``` + +### 4c. Add a case to `buildConfiguration()` switch statement: + +```typescript +case OrganizationIntegrationType.MyService: { + const config = this.convertToJson(configuration); + return this.buildMyServiceConfiguration(config.uri, config.apiKey, config.bw_serviceName); +} +``` + +This allows deserialization of JSON configuration strings from the API into typed objects. + +### 4d. Add a case to `buildTemplate()` switch statement: + +```typescript +case OrganizationIntegrationType.MyService: { + const template = this.convertToJson(template); + return this.buildMyServiceTemplate(template.bw_serviceName); +} +``` + +This allows deserialization of JSON template strings from the API into typed objects. + +## How This Facilitates OrganizationIntegrationService + +The `OrgIntegrationBuilder` acts as an abstraction layer that enables the `OrganizationIntegrationService` to: + +1. **Save/Update Operations**: Accept strongly-typed configuration and template objects, serialize them via `toString()`, + and send to the API as JSON strings. + +2. **Load Operations**: Receive JSON strings from the API, use `buildConfiguration()` and `buildTemplate()` to + deserialize them into strongly-typed objects through the builder's factory methods. + +3. **Type Safety**: Work with typed domain models (`OrgIntegrationConfiguration`, `OrgIntegrationTemplate`) without + knowing the specific implementation details of each integration type. + +4. **Extensibility**: Add new integration types without modifying the service layer logic. The service only needs to + call the builder's methods, which internally route to the correct implementation based on `OrganizationIntegrationType`. + +5. **Property Normalization**: The builder's `normalizePropertyCase()` method handles conversion between PascalCase + (backend) and camelCase (frontend), ensuring seamless data flow regardless of API naming conventions. + +The service uses these capabilities in methods like `save()`, `update()`, and `mapResponsesToOrganizationIntegration()` +to manage the complete lifecycle of integration configurations and templates. + +## Step 5: Add Service Name to OrganizationIntegrationServiceName + +If you're adding a new external service (not just a new integration type for an existing service), +add it to the `OrganizationIntegrationServiceName` enum in `organization-integration-service-type.ts`: + +```typescript +export const OrganizationIntegrationServiceName = Object.freeze({ + CrowdStrike: "CrowdStrike", + Datadog: "Datadog", + MyService: "MyService", // Add your new service +} as const); +``` + +This identifies the external service your integration connects to. The `bw_serviceName` property in your +configuration and template classes should use a value from this enum. + +## Step 6: File Organization + +Place your new files in the following directories: + +- **Configuration classes**: `models/configuration/` + - Example: `models/configuration/myservice-configuration.ts` +- **Template classes**: `models/integration-configuration-config/configuration-template/` + - Example: `models/integration-configuration-config/configuration-template/myservice-template.ts` + +This organization keeps related files grouped and maintains consistency with existing integrations. + +## Important Conventions + +### Template Placeholders + +Templates support standardized placeholders that are replaced with actual values at runtime. +Use the following format with hashtags: + +**Common placeholders**: + +- `#EventMessage#` - Full event message +- `#Type#` - Event type +- `#CipherId#` - Cipher/item identifier +- `#CollectionId#` - Collection identifier +- `#GroupId#` - Group identifier +- `#PolicyId#` - Policy identifier +- `#UserId#` - User identifier +- `#ActingUserId#` - User performing the action +- `#UserName#` - User's name +- `#UserEmail#` - User's email +- `#ActingUserName#` - Acting user's name +- `#ActingUserEmail#` - Acting user's email +- `#DateIso8601#` - ISO 8601 formatted date +- `#DeviceType#` - Device type +- `#IpAddress#` - IP address +- `#SecretId#` - Secret identifier +- `#ProjectId#` - Project identifier +- `#ServiceAccountId#` - Service account identifier + +These placeholders are processed server-side when events are sent to the external service. +**_Also, these placeholders are determined by the server-side implementation, so ensure your template matches the expected format._** + +## Step 7: Add Tests + +Add comprehensive tests for your new integration in three test files: + +### 7a. Integration Service Tests + +Add tests in `organization-integration-service.spec.ts`: + +```typescript +describe("MyService integration", () => { + it("should save a new MyService integration successfully", async () => { + const config = OrgIntegrationBuilder.buildMyServiceConfiguration( + "https://test.myservice.com", + "test-api-key", + OrganizationIntegrationServiceName.MyService, + ); + const template = OrgIntegrationBuilder.buildMyServiceTemplate( + OrganizationIntegrationServiceName.MyService, + ); + // ... test implementation + }); +}); +``` + +The implementation should cover save, update, delete, and load operations. +This is all that is required to make a new integration type functional within the service. + +--- + +## Understanding the Architecture + +**Workflow**: + +1. Call `setOrganizationId(orgId)` to load integrations for an organization +2. Subscribe to `integrations$` to receive the loaded integrations +3. Any save/update/delete operations automatically update `integrations$` + +The service uses `BehaviorSubject` internally to manage state and emit updates to all subscribers. + +### Error Handling Pattern + +All modification operations (`save()`, `update()`, `delete()`) return `IntegrationModificationResult`: + +```typescript +type IntegrationModificationResult = { + success: boolean; // Operation succeeded + mustBeOwner: boolean; // If false, permission denied (404) - user must be organization owner +}; +``` + +This pattern allows the UI to provide specific feedback when users lack necessary permissions. + +### Configuration vs Template + +Understanding the distinction between these two concepts is crucial: + +**Configuration (`OrgIntegrationConfiguration`)**: + +- Contains authentication and connection details +- Example: API URLs, tokens, API keys, authentication schemes +- Stored in the `Integration` record +- Usually contains sensitive data +- One per integration + +**Template (`OrgIntegrationTemplate`)**: + +- Defines the structure and format of event data +- Contains placeholders like `#UserId#`, `#EventMessage#` +- Stored in the `IntegrationConfiguration` record +- No sensitive data +- Specifies how Bitwarden events map to external service format +- One per integration (current implementation) + +When an event occurs, the system: + +1. Uses the **Configuration** to know where and how to send data +2. Uses the **Template** to format the event data for that specific service + +## Example: Complete Integration + +Here's a minimal example showing all pieces working together: + +```typescript +// 1. Configuration +export class ExampleConfiguration implements OrgIntegrationConfiguration { + uri: string; + apiKey: string; + bw_serviceName: OrganizationIntegrationServiceName; + + constructor(uri: string, apiKey: string, bw_serviceName: OrganizationIntegrationServiceName) { + this.uri = uri; + this.apiKey = apiKey; + this.bw_serviceName = bw_serviceName; + } + + toString(): string { + return JSON.stringify({ + Uri: this.uri, + ApiKey: this.apiKey, + bw_serviceName: this.bw_serviceName, + }); + } +} + +// 2. Template +export class ExampleTemplate implements OrgIntegrationTemplate { + bw_serviceName: OrganizationIntegrationServiceName; + + constructor(bw_serviceName: OrganizationIntegrationServiceName) { + this.bw_serviceName = bw_serviceName; + } + + toString(): string { + return JSON.stringify({ + bw_serviceName: this.bw_serviceName, + event: { + type: "#Type#", + user: "#UserEmail#", + timestamp: "#DateIso8601#", + }, + }); + } +} + +// 3. Usage in OrganizationIntegrationService +const config = OrgIntegrationBuilder.buildExampleConfiguration( + "https://api.example.com", + "secret-key", + OrganizationIntegrationServiceName.Example, +); + +const template = OrgIntegrationBuilder.buildExampleTemplate( + OrganizationIntegrationServiceName.Example, +); + +await service.save(orgId, OrganizationIntegrationType.Example, config, template); +``` + +This creates a complete integration that can authenticate with the external service and format event data appropriately. From 97312aaaa01776d52cd06ba5a8cc2bdd30a81e74 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:15:39 +0100 Subject: [PATCH 048/101] Add 'verifysign' to tools owned dependencies (#18239) --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b402d01e209..1b6522c94dd 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -298,6 +298,7 @@ "oidc-client-ts", "papaparse", "utf-8-validate", + "verifysign", "zxcvbn", ], description: "Tools owned dependencies", From bb318ee22e5d5a0e12c6ebd3eb80b4c1aa56b77c Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 7 Jan 2026 15:27:41 -0500 Subject: [PATCH 049/101] Ac/pm 26364 extension UI for auto confirm (#17258) * create nav link for auto confirm in settings page * wip * WIP * create auto confirm library * migrate auto confirm files to lib * update imports * fix tests * fix nudge * cleanup, add documentation * clean up * cleanup * fix import * fix more imports * add tests * design changes * fix tests * fix tw issue * fix typo, add tests * CR feedback * more clean up, fix race condition * CR feedback, cache policies, refactor tests * run prettier with updated version * clean up duplicate logic * clean up * fix test * add missing prop for test mock * clean up --- .github/CODEOWNERS | 1 + apps/browser/src/_locales/en/messages.json | 18 ++ .../account-security.component.spec.ts | 7 + apps/browser/src/popup/app-routing.module.ts | 8 + .../src/popup/services/services.module.ts | 28 +- .../popup/settings/settings-v2.component.html | 18 ++ .../settings/settings-v2.component.spec.ts | 9 + .../popup/settings/settings-v2.component.ts | 14 + .../settings/admin-settings.component.html | 41 +++ .../settings/admin-settings.component.spec.ts | 199 ++++++++++++++ .../settings/admin-settings.component.ts | 121 +++++++++ ...to-confirm-edit-policy-dialog.component.ts | 2 +- .../policies/policies.component.spec.ts | 242 +++++++++++------- .../policies/policies.component.ts | 3 +- apps/web/src/app/core/core.module.ts | 7 +- .../src/app/layouts/user-layout.component.ts | 22 +- apps/web/src/app/oss-routing.module.ts | 2 +- .../vault/individual-vault/vault.component.ts | 2 +- jest.config.js | 1 + libs/admin-console/src/common/index.ts | 1 - .../angular/src/admin-console/guards/index.ts | 1 + .../admin-console}/guards/org-policy.guard.ts | 0 .../auto-confirm-nudge.service.spec.ts | 226 ++++++++++++++++ .../auto-confirm-nudge.service.ts | 41 +++ .../services/custom-nudges-services/index.ts | 1 + .../src/vault/services/nudges.service.spec.ts | 6 + .../src/vault/services/nudges.service.ts | 4 + libs/auto-confirm/README.md | 18 ++ libs/auto-confirm/eslint.config.mjs | 3 + libs/auto-confirm/jest.config.js | 18 ++ libs/auto-confirm/package.json | 11 + libs/auto-confirm/project.json | 34 +++ .../auto-confirm.service.abstraction.ts | 6 +- .../src}/abstractions/index.ts | 0 ...auto-confirm-warning-dialog.component.html | 25 ++ .../auto-confirm-warning-dialog.component.ts | 19 ++ libs/auto-confirm/src/components/index.ts | 1 + ...c-user-confirmation-settings.guard.spec.ts | 93 +++++++ ...omatic-user-confirmation-settings.guard.ts | 35 +++ libs/auto-confirm/src/guards/index.ts | 1 + .../src}/index.ts | 2 + .../src}/models/auto-confirm-state.model.ts | 0 .../src}/models/index.ts | 0 .../default-auto-confirm.service.spec.ts | 181 +++++++++---- .../services/default-auto-confirm.service.ts | 26 +- .../src}/services/index.ts | 0 libs/auto-confirm/test.setup.ts | 23 ++ libs/auto-confirm/tsconfig.eslint.json | 6 + libs/auto-confirm/tsconfig.json | 13 + libs/auto-confirm/tsconfig.lib.json | 10 + libs/auto-confirm/tsconfig.spec.json | 10 + .../organization.service.abstraction.ts | 4 +- .../models/domain/organization.ts | 7 + package-lock.json | 9 + tsconfig.base.json | 1 + 55 files changed, 1393 insertions(+), 188 deletions(-) create mode 100644 apps/browser/src/vault/popup/settings/admin-settings.component.html create mode 100644 apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts create mode 100644 apps/browser/src/vault/popup/settings/admin-settings.component.ts create mode 100644 libs/angular/src/admin-console/guards/index.ts rename {apps/web/src/app/admin-console/organizations => libs/angular/src/admin-console}/guards/org-policy.guard.ts (100%) create mode 100644 libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts create mode 100644 libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts create mode 100644 libs/auto-confirm/README.md create mode 100644 libs/auto-confirm/eslint.config.mjs create mode 100644 libs/auto-confirm/jest.config.js create mode 100644 libs/auto-confirm/package.json create mode 100644 libs/auto-confirm/project.json rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/abstractions/auto-confirm.service.abstraction.ts (90%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/abstractions/index.ts (100%) create mode 100644 libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html create mode 100644 libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts create mode 100644 libs/auto-confirm/src/components/index.ts create mode 100644 libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts create mode 100644 libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts create mode 100644 libs/auto-confirm/src/guards/index.ts rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/index.ts (60%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/models/auto-confirm-state.model.ts (100%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/models/index.ts (100%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/services/default-auto-confirm.service.spec.ts (72%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/services/default-auto-confirm.service.ts (75%) rename libs/{admin-console/src/common/auto-confirm => auto-confirm/src}/services/index.ts (100%) create mode 100644 libs/auto-confirm/test.setup.ts create mode 100644 libs/auto-confirm/tsconfig.eslint.json create mode 100644 libs/auto-confirm/tsconfig.json create mode 100644 libs/auto-confirm/tsconfig.lib.json create mode 100644 libs/auto-confirm/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 99efec2fbbb..d1266a174e4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,6 +75,7 @@ bitwarden_license/bit-cli/src/admin-console @bitwarden/team-admin-console-dev libs/angular/src/admin-console @bitwarden/team-admin-console-dev libs/common/src/admin-console @bitwarden/team-admin-console-dev libs/admin-console @bitwarden/team-admin-console-dev +libs/auto-confirm @bitwarden/team-admin-console-dev ## Billing team files ## apps/browser/src/billing @bitwarden/team-billing-dev diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ca9dde99a95..29b39863bc6 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin" :{ + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout":{ + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index ebabbadf71c..d1380f5eae0 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -8,6 +8,7 @@ import { firstValueFrom, of, BehaviorSubject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { NudgesService } from "@bitwarden/angular/vault"; import { LockService } from "@bitwarden/auth/common"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -124,6 +125,12 @@ describe("AccountSecurityComponent", () => { { provide: ToastService, useValue: mock() }, { provide: UserVerificationService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, + { provide: LockService, useValue: lockService }, + { + provide: AutomaticUserConfirmationService, + useValue: mock(), + }, + { provide: ConfigService, useValue: configService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, ], }) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 12e1288e806..6838d4940ab 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -42,6 +42,7 @@ import { TwoFactorAuthComponent, TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; +import { canAccessAutoConfirmSettings } from "@bitwarden/auto-confirm"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent, @@ -90,6 +91,7 @@ import { } from "../vault/popup/guards/at-risk-passwords.guard"; import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; +import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component"; @@ -332,6 +334,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, + { + path: "admin", + component: AdminSettingsComponent, + canActivate: [authGuard, canAccessAutoConfirmSettings], + data: { elevation: 1 } satisfies RouteDataProperties, + }, { path: "clone-cipher", component: AddEditV2Component, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index cb6ee51f98c..c462e798a42 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -3,7 +3,11 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { merge, of, Subject } from "rxjs"; -import { CollectionService } from "@bitwarden/admin-console/common"; +import { + CollectionService, + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; @@ -40,11 +44,18 @@ import { LogoutService, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + InternalOrganizationServiceAbstraction, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService, @@ -745,6 +756,19 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: AutomaticUserConfirmationService, + useClass: DefaultAutomaticUserConfirmationService, + deps: [ + ConfigService, + ApiService, + OrganizationUserService, + StateProvider, + InternalOrganizationServiceAbstraction, + OrganizationUserApiService, + PolicyService, + ], + }), safeProvider({ provide: SessionTimeoutTypeService, useClass: BrowserSessionTimeoutTypeService, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 06c89e15f59..c6f1c9dbc3b 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -82,6 +82,24 @@ + + @if (showAdminSettingsLink$ | async) { + + + +
+

{{ "admin" | i18n }}

+ @if (showAdminBadge$ | async) { + 1 + } +
+ +
+
+ } + diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts index 4cc3ed0149c..a05fa45753e 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service"; import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -42,6 +43,9 @@ describe("SettingsV2Component", () => { defaultBrowserAutofillDisabled$: Subject; isBrowserAutofillSettingOverridden: jest.Mock>; }; + let mockAutoConfirmService: { + canManageAutoConfirm$: jest.Mock; + }; let dialogService: MockProxy; let openSpy: jest.SpyInstance; @@ -66,6 +70,10 @@ describe("SettingsV2Component", () => { isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false), }; + mockAutoConfirmService = { + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + }; + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome"); const cfg = TestBed.configureTestingModule({ @@ -75,6 +83,7 @@ describe("SettingsV2Component", () => { { provide: BillingAccountProfileStateService, useValue: mockBillingState }, { provide: NudgesService, useValue: mockNudges }, { provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings }, + { provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: { t: jest.fn((key: string) => key) } }, { provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() }, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index e10d41b9445..2c9f893c99c 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -7,7 +7,9 @@ import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/compon import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -65,13 +67,25 @@ export class SettingsV2Component { ), ); + showAdminBadge$: Observable = this.authenticatedAccount$.pipe( + switchMap((account) => + this.nudgesService.showNudgeBadge$(NudgeType.AutoConfirmNudge, account.id), + ), + ); + showAutofillBadge$: Observable = this.authenticatedAccount$.pipe( switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)), ); + showAdminSettingsLink$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.autoConfirmService.canManageAutoConfirm$(userId)), + ); + constructor( private readonly nudgesService: NudgesService, private readonly accountService: AccountService, + private readonly autoConfirmService: AutomaticUserConfirmationService, private readonly accountProfileStateService: BillingAccountProfileStateService, private readonly dialogService: DialogService, ) {} diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.html b/apps/browser/src/vault/popup/settings/admin-settings.component.html new file mode 100644 index 00000000000..5e67750278f --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.html @@ -0,0 +1,41 @@ + + + + + + + +
+ @if (showAutoConfirmSpotlight$ | async) { + +
+ + {{ "autoConfirmOnboardingCallout" | i18n }} + + + +
+
+ } + +
+ + + + + {{ "automaticUserConfirmation" | i18n }} + + + {{ "automaticUserConfirmationHint" | i18n }} + + +
+
+
diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts b/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts new file mode 100644 index 00000000000..f7b4e7b473a --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts @@ -0,0 +1,199 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { AutoConfirmState, AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +import { AdminSettingsComponent } from "./admin-settings.component"; + +@Component({ + selector: "popup-header", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupHeaderComponent { + readonly pageTitle = input(); + readonly backAction = input<() => void>(); +} + +@Component({ + selector: "popup-page", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupPageComponent { + readonly loading = input(); +} + +@Component({ + selector: "app-pop-out", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopOutComponent { + readonly show = input(true); +} + +describe("AdminSettingsComponent", () => { + let component: AdminSettingsComponent; + let fixture: ComponentFixture; + let autoConfirmService: MockProxy; + let nudgesService: MockProxy; + let mockDialogService: MockProxy; + + const userId = "test-user-id" as UserId; + const mockAutoConfirmState: AutoConfirmState = { + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }; + + beforeEach(async () => { + autoConfirmService = mock(); + nudgesService = mock(); + mockDialogService = mock(); + + autoConfirmService.configuration$.mockReturnValue(of(mockAutoConfirmState)); + autoConfirmService.upsert.mockResolvedValue(undefined); + nudgesService.showNudgeSpotlight$.mockReturnValue(of(false)); + + await TestBed.configureTestingModule({ + imports: [AdminSettingsComponent], + providers: [ + provideNoopAnimations(), + { provide: AccountService, useValue: mockAccountServiceWith(userId) }, + { provide: AutomaticUserConfirmationService, useValue: autoConfirmService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: NudgesService, useValue: nudgesService }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }) + .overrideComponent(AdminSettingsComponent, { + remove: { + imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminSettingsComponent); + component = fixture.componentInstance; + }); + + describe("initialization", () => { + it("should populate form with current auto-confirm state", async () => { + const mockState: AutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + autoConfirmService.configuration$.mockReturnValue(of(mockState)); + + await component.ngOnInit(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component["adminForm"].value).toEqual({ + autoConfirm: true, + }); + }); + + it("should populate form with disabled auto-confirm state", async () => { + await component.ngOnInit(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component["adminForm"].value).toEqual({ + autoConfirm: false, + }); + }); + }); + + describe("spotlight", () => { + beforeEach(async () => { + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("should expose showAutoConfirmSpotlight$ observable", (done) => { + nudgesService.showNudgeSpotlight$.mockReturnValue(of(true)); + + const newFixture = TestBed.createComponent(AdminSettingsComponent); + const newComponent = newFixture.componentInstance; + + newComponent["showAutoConfirmSpotlight$"].subscribe((show) => { + expect(show).toBe(true); + expect(nudgesService.showNudgeSpotlight$).toHaveBeenCalledWith( + NudgeType.AutoConfirmNudge, + userId, + ); + done(); + }); + }); + + it("should dismiss spotlight and update state", async () => { + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, { + ...mockAutoConfirmState, + showBrowserNotification: false, + }); + }); + + it("should use current userId when dismissing spotlight", async () => { + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, expect.any(Object)); + }); + + it("should preserve existing state when dismissing spotlight", async () => { + const customState: AutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + autoConfirmService.configuration$.mockReturnValue(of(customState)); + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, { + ...customState, + showBrowserNotification: false, + }); + }); + }); + + describe("form validation", () => { + beforeEach(async () => { + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("should have a valid form", () => { + expect(component["adminForm"].valid).toBe(true); + }); + + it("should have autoConfirm control", () => { + expect(component["adminForm"].controls.autoConfirm).toBeDefined(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.ts b/apps/browser/src/vault/popup/settings/admin-settings.component.ts new file mode 100644 index 00000000000..e4b676525ed --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.ts @@ -0,0 +1,121 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + signal, + WritableSignal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom, map, Observable, of, switchMap, tap, withLatestFrom } from "rxjs"; + +import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; +import { + AutoConfirmWarningDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; +import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "@bitwarden/browser/platform/popup/layout/popup-page.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + BitIconButtonComponent, + CardComponent, + DialogService, + FormFieldModule, + SwitchComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { UserId } from "@bitwarden/user-core"; + +@Component({ + templateUrl: "./admin-settings.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + FormFieldModule, + ReactiveFormsModule, + SwitchComponent, + CardComponent, + SpotlightComponent, + BitIconButtonComponent, + I18nPipe, + ], +}) +export class AdminSettingsComponent implements OnInit { + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + protected readonly formLoading: WritableSignal = signal(true); + protected adminForm = this.formBuilder.group({ + autoConfirm: false, + }); + protected showAutoConfirmSpotlight$: Observable = this.userId$.pipe( + switchMap((userId) => + this.nudgesService.showNudgeSpotlight$(NudgeType.AutoConfirmNudge, userId), + ), + ); + + constructor( + private formBuilder: FormBuilder, + private accountService: AccountService, + private autoConfirmService: AutomaticUserConfirmationService, + private destroyRef: DestroyRef, + private dialogService: DialogService, + private nudgesService: NudgesService, + ) {} + + async ngOnInit() { + const userId = await firstValueFrom(this.userId$); + const autoConfirmEnabled = ( + await firstValueFrom(this.autoConfirmService.configuration$(userId)) + ).enabled; + this.adminForm.setValue({ autoConfirm: autoConfirmEnabled }); + + this.formLoading.set(false); + + this.adminForm.controls.autoConfirm.valueChanges + .pipe( + switchMap((newValue) => { + if (newValue) { + return this.confirm(); + } + return of(false); + }), + withLatestFrom(this.autoConfirmService.configuration$(userId)), + switchMap(([newValue, existingState]) => + this.autoConfirmService.upsert(userId, { + ...existingState, + enabled: newValue, + showBrowserNotification: false, + }), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + private confirm(): Observable { + return AutoConfirmWarningDialogComponent.open(this.dialogService).closed.pipe( + map((result) => result ?? false), + tap((result) => { + if (!result) { + this.adminForm.setValue({ autoConfirm: false }, { emitEvent: false }); + } + }), + ); + } + + async dismissSpotlight() { + const userId = await firstValueFrom(this.userId$); + const state = await firstValueFrom(this.autoConfirmService.configuration$(userId)); + + await this.autoConfirmService.upsert(userId, { ...state, showBrowserNotification: false }); + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts index 63a8a4341d6..9dfb8ebb7e7 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -22,7 +22,7 @@ import { tap, } from "rxjs"; -import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts index 0e025a9d52a..125876ce05a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts @@ -188,7 +188,7 @@ describe("PoliciesComponent", () => { }); describe("orgPolicies$", () => { - it("should fetch policies from API for current organization", async () => { + describe("with multiple policies", () => { const mockPolicyResponsesData = [ { id: newGuid(), @@ -206,39 +206,63 @@ describe("PoliciesComponent", () => { }, ]; - const listResponse = new ListResponse( - { Data: mockPolicyResponsesData, ContinuationToken: null }, - PolicyResponse, - ); + beforeEach(async () => { + const listResponse = new ListResponse( + { Data: mockPolicyResponsesData, ContinuationToken: null }, + PolicyResponse, + ); - mockPolicyApiService.getPolicies.mockResolvedValue(listResponse); + mockPolicyApiService.getPolicies.mockResolvedValue(listResponse); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual(listResponse.data); - expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should fetch policies from API for current organization", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies.length).toBe(2); + expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId); + }); }); - it("should return empty array when API returns no data", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), - ); + describe("with no policies", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), + ); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual([]); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should return empty array when API returns no data", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies).toEqual([]); + }); }); - it("should return empty array when API returns null data", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse), - ); + describe("with null data", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse), + ); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual([]); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should return empty array when API returns null data", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies).toEqual([]); + }); }); }); describe("policiesEnabledMap$", () => { - it("should create a map of policy types to their enabled status", async () => { + describe("with multiple policies", () => { const mockPolicyResponsesData = [ { id: "policy-1", @@ -263,27 +287,43 @@ describe("PoliciesComponent", () => { }, ]; - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse( - { Data: mockPolicyResponsesData, ContinuationToken: null }, - PolicyResponse, - ), - ); + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse( + { Data: mockPolicyResponsesData, ContinuationToken: null }, + PolicyResponse, + ), + ); - const map = await firstValueFrom(component.policiesEnabledMap$); - expect(map.size).toBe(3); - expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true); - expect(map.get(PolicyType.RequireSso)).toBe(false); - expect(map.get(PolicyType.SingleOrg)).toBe(true); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create a map of policy types to their enabled status", async () => { + const map = await firstValueFrom(component.policiesEnabledMap$); + expect(map.size).toBe(3); + expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true); + expect(map.get(PolicyType.RequireSso)).toBe(false); + expect(map.get(PolicyType.SingleOrg)).toBe(true); + }); }); - it("should create empty map when no policies exist", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), - ); + describe("with no policies", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), + ); - const map = await firstValueFrom(component.policiesEnabledMap$); - expect(map.size).toBe(0); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create empty map when no policies exist", async () => { + const map = await firstValueFrom(component.policiesEnabledMap$); + expect(map.size).toBe(0); + }); }); }); @@ -292,31 +332,36 @@ describe("PoliciesComponent", () => { expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId); }); - it("should refresh policies when policyService emits", async () => { - const policiesSubject = new BehaviorSubject([]); - mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable()); + describe("when policyService emits", () => { + let policiesSubject: BehaviorSubject; + let callCount: number; - let callCount = 0; - mockPolicyApiService.getPolicies.mockImplementation(() => { - callCount++; - return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse)); + beforeEach(async () => { + policiesSubject = new BehaviorSubject([]); + mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable()); + + callCount = 0; + mockPolicyApiService.getPolicies.mockImplementation(() => { + callCount++; + return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse)); + }); + + fixture = TestBed.createComponent(PoliciesComponent); + fixture.detectChanges(); }); - const newFixture = TestBed.createComponent(PoliciesComponent); - newFixture.detectChanges(); + it("should refresh policies when policyService emits", () => { + const initialCallCount = callCount; - const initialCallCount = callCount; + policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]); - policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]); - - expect(callCount).toBeGreaterThan(initialCallCount); - - newFixture.destroy(); + expect(callCount).toBeGreaterThan(initialCallCount); + }); }); }); describe("handleLaunchEvent", () => { - it("should open policy dialog when policyId is in query params", async () => { + describe("when policyId is in query params", () => { const mockPolicyId = newGuid(); const mockPolicy: BasePolicyEditDefinition = { name: "Test Policy", @@ -335,54 +380,59 @@ describe("PoliciesComponent", () => { data: null, }; - queryParamsSubject.next({ policyId: mockPolicyId }); + let dialogOpenSpy: jest.SpyInstance; - mockPolicyApiService.getPolicies.mockReturnValue( - of( - new ListResponse( - { Data: [mockPolicyResponseData], ContinuationToken: null }, - PolicyResponse, + beforeEach(async () => { + queryParamsSubject.next({ policyId: mockPolicyId }); + + mockPolicyApiService.getPolicies.mockReturnValue( + of( + new ListResponse( + { Data: [mockPolicyResponseData], ContinuationToken: null }, + PolicyResponse, + ), ), - ), - ); + ); - const dialogOpenSpy = jest - .spyOn(PolicyEditDialogComponent, "open") - .mockReturnValue({ close: jest.fn() } as any); + dialogOpenSpy = jest + .spyOn(PolicyEditDialogComponent, "open") + .mockReturnValue({ close: jest.fn() } as any); - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [PoliciesComponent], - providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: OrganizationService, useValue: mockOrganizationService }, - { provide: AccountService, useValue: mockAccountService }, - { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, - { provide: PolicyListService, useValue: mockPolicyListService }, - { provide: DialogService, useValue: mockDialogService }, - { provide: PolicyService, useValue: mockPolicyService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: I18nService, useValue: mockI18nService }, - { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, - { provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] }, - ], - schemas: [NO_ERRORS_SCHEMA], - }) - .overrideComponent(PoliciesComponent, { - remove: { imports: [] }, - add: { template: "
" }, + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [PoliciesComponent], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { provide: PolicyListService, useValue: mockPolicyListService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] }, + ], + schemas: [NO_ERRORS_SCHEMA], }) - .compileComponents(); + .overrideComponent(PoliciesComponent, { + remove: { imports: [] }, + add: { template: "
" }, + }) + .compileComponents(); - const newFixture = TestBed.createComponent(PoliciesComponent); - newFixture.detectChanges(); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); - expect(dialogOpenSpy).toHaveBeenCalled(); - const callArgs = dialogOpenSpy.mock.calls[0][1]; - expect(callArgs.data?.policy.type).toBe(mockPolicy.type); - expect(callArgs.data?.organizationId).toBe(mockOrgId); - - newFixture.destroy(); + it("should open policy dialog when policyId is in query params", () => { + expect(dialogOpenSpy).toHaveBeenCalled(); + const callArgs = dialogOpenSpy.mock.calls[0][1]; + expect(callArgs.data?.policy.type).toBe(mockPolicy.type); + expect(callArgs.data?.organizationId).toBe(mockOrgId); + }); }); it("should not open dialog when policyId is not in query params", async () => { diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 70daf55f662..1f9a8deaa85 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, Observable, of, switchMap, first, map } from "rxjs"; +import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -70,6 +70,7 @@ export class PoliciesComponent { switchMap(() => this.organizationId$), switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)), map((response) => (response.data != null && response.data.length > 0 ? response.data : [])), + shareReplay({ bufferSize: 1, refCount: true }), ); protected policiesEnabledMap$: Observable> = this.orgPolicies$.pipe( diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index e436e194e9e..661d14502fe 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -9,8 +9,6 @@ import { DefaultCollectionAdminService, OrganizationUserApiService, CollectionService, - AutomaticUserConfirmationService, - DefaultAutomaticUserConfirmationService, OrganizationUserService, DefaultOrganizationUserService, } from "@bitwarden/admin-console/common"; @@ -46,6 +44,10 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, } from "@bitwarden/auth/common"; +import { + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -376,6 +378,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, InternalOrganizationServiceAbstraction, OrganizationUserApiService, + PolicyService, ], }), safeProvider({ diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 3af514466b7..90207f59ad4 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -4,12 +4,12 @@ import { CommonModule } from "@angular/common"; import { Component, OnInit, Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; -import { combineLatest, map, Observable, switchMap } from "rxjs"; +import { Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -58,21 +58,11 @@ export class UserLayoutComponent implements OnInit { ); this.showEmergencyAccess = toSignal( - combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), - ), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + canAccessEmergencyAccess(userId, this.configService, this.policyService), ), - ]).pipe( - map(([enabled, policyAppliesToUser]) => { - if (!enabled || !policyAppliesToUser) { - return true; - } - return false; - }), ), ); diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index f4fd55bd1e6..932d0b8119b 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; +import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { @@ -56,7 +57,6 @@ import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/gua import { flagEnabled, Flags } from "../utils/flags"; -import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index a5121831304..aa238922eea 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -26,7 +26,6 @@ import { } from "rxjs/operators"; import { - AutomaticUserConfirmationService, CollectionData, CollectionDetailsResponse, CollectionService, @@ -42,6 +41,7 @@ import { ItemTypes, Icon, } from "@bitwarden/assets/svg"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { diff --git a/jest.config.js b/jest.config.js index 37d15eb8f92..bfe447f7a53 100644 --- a/jest.config.js +++ b/jest.config.js @@ -59,6 +59,7 @@ module.exports = { "/libs/tools/send/send-ui/jest.config.js", "/libs/user-core/jest.config.js", "/libs/vault/jest.config.js", + "/libs/auto-confirm/jest.config.js", "/libs/subscription/jest.config.js", ], diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index 37f79d56256..5178805cec5 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1,3 +1,2 @@ -export * from "./auto-confirm"; export * from "./collections"; export * from "./organization-user"; diff --git a/libs/angular/src/admin-console/guards/index.ts b/libs/angular/src/admin-console/guards/index.ts new file mode 100644 index 00000000000..71f34285761 --- /dev/null +++ b/libs/angular/src/admin-console/guards/index.ts @@ -0,0 +1 @@ +export * from "./org-policy.guard"; diff --git a/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts b/libs/angular/src/admin-console/guards/org-policy.guard.ts similarity index 100% rename from apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts rename to libs/angular/src/admin-console/guards/org-policy.guard.ts diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts new file mode 100644 index 00000000000..4e8d1ed3d1a --- /dev/null +++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts @@ -0,0 +1,226 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/user-core"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec"; +import { NUDGE_DISMISSED_DISK_KEY, NudgeType } from "../nudges.service"; + +import { AutoConfirmNudgeService } from "./auto-confirm-nudge.service"; + +describe("AutoConfirmNudgeService", () => { + let service: AutoConfirmNudgeService; + let autoConfirmService: MockProxy; + let fakeStateProvider: FakeStateProvider; + const userId = "user-id" as UserId; + + const mockAutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + + beforeEach(() => { + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + autoConfirmService = mock(); + + TestBed.configureTestingModule({ + providers: [ + AutoConfirmNudgeService, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmService, + }, + ], + }); + + service = TestBed.inject(AutoConfirmNudgeService); + }); + + describe("nudgeStatus$", () => { + it("should return all dismissed when user cannot manage auto-confirm", async () => { + autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState)); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should return all dismissed when showBrowserNotification is false", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: false, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should return not dismissed when showBrowserNotification is true and user can manage", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }); + }); + + it("should return not dismissed when showBrowserNotification is undefined and user can manage", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: undefined, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }); + }); + + it("should return stored nudge status when badge is already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }); + }); + + it("should return stored nudge status when spotlight is already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: false, + hasSpotlightDismissed: true, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: true, + }); + }); + + it("should return stored nudge status when both badge and spotlight are already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should prioritize user permissions over showBrowserNotification setting", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should respect stored dismissal even when user cannot manage auto-confirm", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState)); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + }); +}); diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts new file mode 100644 index 00000000000..52fc87d7604 --- /dev/null +++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts @@ -0,0 +1,41 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { UserId } from "@bitwarden/user-core"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeType, NudgeStatus } from "../nudges.service"; + +@Injectable({ providedIn: "root" }) +export class AutoConfirmNudgeService extends DefaultSingleNudgeService { + autoConfirmService = inject(AutomaticUserConfirmationService); + + nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.autoConfirmService.configuration$(userId), + this.autoConfirmService.canManageAutoConfirm$(userId), + ]).pipe( + map(([nudgeStatus, autoConfirmState, canManageAutoConfirm]) => { + if (!canManageAutoConfirm) { + return { + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }; + } + + if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) { + return nudgeStatus; + } + + const dismissed = autoConfirmState.showBrowserNotification === false; + + return { + hasBadgeDismissed: dismissed, + hasSpotlightDismissed: dismissed, + }; + }), + ); + } +} diff --git a/libs/angular/src/vault/services/custom-nudges-services/index.ts b/libs/angular/src/vault/services/custom-nudges-services/index.ts index d4bfe80a525..030a46c10b2 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/index.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/index.ts @@ -1,4 +1,5 @@ export * from "./account-security-nudge.service"; +export * from "./auto-confirm-nudge.service"; export * from "./has-items-nudge.service"; export * from "./empty-vault-nudge.service"; export * from "./vault-settings-import-nudge.service"; diff --git a/libs/angular/src/vault/services/nudges.service.spec.ts b/libs/angular/src/vault/services/nudges.service.spec.ts index cba973bd894..346b22bf122 100644 --- a/libs/angular/src/vault/services/nudges.service.spec.ts +++ b/libs/angular/src/vault/services/nudges.service.spec.ts @@ -23,6 +23,7 @@ import { AccountSecurityNudgeService, VaultSettingsImportNudgeService, } from "./custom-nudges-services"; +import { AutoConfirmNudgeService } from "./custom-nudges-services/auto-confirm-nudge.service"; import { DefaultSingleNudgeService } from "./default-single-nudge.service"; import { NudgesService, NudgeType } from "./nudges.service"; @@ -35,6 +36,7 @@ describe("Vault Nudges Service", () => { EmptyVaultNudgeService, NewAccountNudgeService, AccountSecurityNudgeService, + AutoConfirmNudgeService, ]; beforeEach(async () => { @@ -73,6 +75,10 @@ describe("Vault Nudges Service", () => { provide: VaultSettingsImportNudgeService, useValue: mock(), }, + { + provide: AutoConfirmNudgeService, + useValue: mock(), + }, { provide: ApiService, useValue: mock(), diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts index 19acf690d32..afd0d184d6e 100644 --- a/libs/angular/src/vault/services/nudges.service.ts +++ b/libs/angular/src/vault/services/nudges.service.ts @@ -12,6 +12,7 @@ import { NewItemNudgeService, AccountSecurityNudgeService, VaultSettingsImportNudgeService, + AutoConfirmNudgeService, NoOpNudgeService, } from "./custom-nudges-services"; import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; @@ -39,6 +40,7 @@ export const NudgeType = { NewNoteItemStatus: "new-note-item-status", NewSshItemStatus: "new-ssh-item-status", GeneratorNudgeStatus: "generator-nudge-status", + AutoConfirmNudge: "auto-confirm-nudge", PremiumUpgrade: "premium-upgrade", } as const; @@ -82,6 +84,7 @@ export class NudgesService { [NudgeType.NewIdentityItemStatus]: this.newItemNudgeService, [NudgeType.NewNoteItemStatus]: this.newItemNudgeService, [NudgeType.NewSshItemStatus]: this.newItemNudgeService, + [NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService), }; /** @@ -148,6 +151,7 @@ export class NudgesService { NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden, NudgeType.AutofillNudge, + NudgeType.AutoConfirmNudge, ]; const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => { diff --git a/libs/auto-confirm/README.md b/libs/auto-confirm/README.md new file mode 100644 index 00000000000..15779018b90 --- /dev/null +++ b/libs/auto-confirm/README.md @@ -0,0 +1,18 @@ +# Automatic User Confirmation + +Owned by: admin-console + +The automatic user confirmation (auto confirm) feature enables an organization to confirm users to an organization without manual intervention +from any user as long as an administrator's device is unlocked. The feature is enabled via the following: + +1. an organization plan feature in the Bitwarden Portal (enabled by an internal team) +2. the automatic user confirmation policy in the Admin Console (enabled by an organization admin) +3. a toggle switch in the extension's admin settings page (enabled on the admin's local device) + +Once these three toggles are enabled, auto confirm will be enabled and users will be auto confirmed as long as an admin is logged in. Note that the setting in +the browser extension is not synced across clients, therefore it will not be enabled if the same admin logs into another browser until it is enabled in that +browser. This is an intentional security measure to ensure that the server cannot enable the feature unilaterally. + +Once enabled, the AutomaticUserConfirmationService runs in the background on admins' devices and reacts to push notifications from the server containing organization members who need confirmation. + +For more information about security goals and the push notification system, see [README in server repo](https://github.com/bitwarden/server/tree/main/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser). diff --git a/libs/auto-confirm/eslint.config.mjs b/libs/auto-confirm/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/auto-confirm/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/auto-confirm/jest.config.js b/libs/auto-confirm/jest.config.js new file mode 100644 index 00000000000..461c4ef5602 --- /dev/null +++ b/libs/auto-confirm/jest.config.js @@ -0,0 +1,18 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../tsconfig.base"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +module.exports = { + ...sharedConfig, + displayName: "auto-confirm", + setupFilesAfterEnv: ["/test.setup.ts"], + coverageDirectory: "../../coverage/libs/auto-confirm", + moduleNameMapper: pathsToModuleNameMapper( + { "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/../../", + }, + ), +}; diff --git a/libs/auto-confirm/package.json b/libs/auto-confirm/package.json new file mode 100644 index 00000000000..6bb4a334d6a --- /dev/null +++ b/libs/auto-confirm/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/auto-confirm", + "version": "0.0.1", + "description": "auto confirm", + "private": true, + "type": "commonjs", + "main": "index.js", + "types": "index.d.ts", + "license": "GPL-3.0", + "author": "admin-console" +} diff --git a/libs/auto-confirm/project.json b/libs/auto-confirm/project.json new file mode 100644 index 00000000000..81efa0c77ca --- /dev/null +++ b/libs/auto-confirm/project.json @@ -0,0 +1,34 @@ +{ + "name": "auto-confirm", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/auto-confirm/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/auto-confirm", + "main": "libs/auto-confirm/src/index.ts", + "tsConfig": "libs/auto-confirm/tsconfig.lib.json", + "assets": ["libs/auto-confirm/*.md"], + "rootDir": "libs/auto-confirm/src" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/auto-confirm/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/auto-confirm/jest.config.js" + } + } + } +} diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts b/libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts similarity index 90% rename from libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts rename to libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts index e753184273e..9ce6cb9c1a4 100644 --- a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts +++ b/libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts @@ -1,7 +1,6 @@ import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/user-core"; import { AutoConfirmState } from "../models/auto-confirm-state.model"; @@ -24,10 +23,7 @@ export abstract class AutomaticUserConfirmationService { * @param userId * @returns Observable an observable with a boolean telling us if the provided user may confgure the auto confirm feature. **/ - abstract canManageAutoConfirm$( - userId: UserId, - organizationId: OrganizationId, - ): Observable; + abstract canManageAutoConfirm$(userId: UserId): Observable; /** * Calls the API endpoint to initiate automatic user confirmation. * @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks. diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/index.ts b/libs/auto-confirm/src/abstractions/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/abstractions/index.ts rename to libs/auto-confirm/src/abstractions/index.ts diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html new file mode 100644 index 00000000000..d1697c1968d --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html @@ -0,0 +1,25 @@ + + + {{ "warningCapitalized" | i18n }} + + + {{ "autoConfirmWarning" | i18n }} +
+ {{ "autoConfirmWarningLink" | i18n }} + + + + + + + + diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts new file mode 100644 index 00000000000..f126ce3b92c --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -0,0 +1,19 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./auto-confirm-warning-dialog.component.html", + imports: [ButtonModule, DialogModule, CommonModule, I18nPipe], +}) +export class AutoConfirmWarningDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmWarningDialogComponent); + } +} diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts new file mode 100644 index 00000000000..a0310e805c6 --- /dev/null +++ b/libs/auto-confirm/src/components/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm-warning-dialog.component"; diff --git a/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts new file mode 100644 index 00000000000..aca51edb8dc --- /dev/null +++ b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from "@angular/core/testing"; +import { Router, UrlTree } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; + +import { AutomaticUserConfirmationService } from "../abstractions"; + +import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard"; + +describe("canAccessAutoConfirmSettings", () => { + let accountService: MockProxy; + let autoConfirmService: MockProxy; + let toastService: MockProxy; + let i18nService: MockProxy; + let router: MockProxy; + + const mockUserId = newGuid() as UserId; + const mockAccount: Account = { + id: mockUserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + creationDate: undefined, + }; + let activeAccount$: BehaviorSubject; + + const runGuard = () => { + return TestBed.runInInjectionContext(() => { + return canAccessAutoConfirmSettings(null as any, null as any) as Observable< + boolean | UrlTree + >; + }); + }; + + beforeEach(() => { + accountService = mock(); + autoConfirmService = mock(); + toastService = mock(); + i18nService = mock(); + router = mock(); + + activeAccount$ = new BehaviorSubject(mockAccount); + accountService.activeAccount$ = activeAccount$; + + TestBed.configureTestingModule({ + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: AutomaticUserConfirmationService, useValue: autoConfirmService }, + { provide: ToastService, useValue: toastService }, + { provide: I18nService, useValue: i18nService }, + { provide: Router, useValue: router }, + ], + }); + }); + + it("should allow access when user has permission", async () => { + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true)); + + const result = await firstValueFrom(runGuard()); + + expect(result).toBe(true); + }); + + it("should redirect to vault when user lacks permission", async () => { + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(false)); + const mockUrlTree = {} as UrlTree; + router.createUrlTree.mockReturnValue(mockUrlTree); + + const result = await firstValueFrom(runGuard()); + + expect(result).toBe(mockUrlTree); + expect(router.createUrlTree).toHaveBeenCalledWith(["/tabs/vault"]); + }); + + it("should not emit when active account is null", async () => { + activeAccount$.next(null); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true)); + + let guardEmitted = false; + const subscription = runGuard().subscribe(() => { + guardEmitted = true; + }); + + expect(guardEmitted).toBe(false); + subscription.unsubscribe(); + }); +}); diff --git a/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts new file mode 100644 index 00000000000..77f01ba2801 --- /dev/null +++ b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts @@ -0,0 +1,35 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { map, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { ToastService } from "@bitwarden/components"; + +import { AutomaticUserConfirmationService } from "../abstractions"; + +export const canAccessAutoConfirmSettings: CanActivateFn = () => { + const accountService = inject(AccountService); + const autoConfirmService = inject(AutomaticUserConfirmationService); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + const router = inject(Router); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => autoConfirmService.canManageAutoConfirm$(user.id)), + map((canManageAutoConfirm) => { + if (!canManageAutoConfirm) { + toastService.showToast({ + variant: "error", + title: "", + message: i18nService.t("noPermissionsViewPage"), + }); + + return router.createUrlTree(["/tabs/vault"]); + } + return true; + }), + ); +}; diff --git a/libs/auto-confirm/src/guards/index.ts b/libs/auto-confirm/src/guards/index.ts new file mode 100644 index 00000000000..fa635bcb9e1 --- /dev/null +++ b/libs/auto-confirm/src/guards/index.ts @@ -0,0 +1 @@ +export * from "./automatic-user-confirmation-settings.guard"; diff --git a/libs/admin-console/src/common/auto-confirm/index.ts b/libs/auto-confirm/src/index.ts similarity index 60% rename from libs/admin-console/src/common/auto-confirm/index.ts rename to libs/auto-confirm/src/index.ts index 9187ccd39cf..56b9d0b0285 100644 --- a/libs/admin-console/src/common/auto-confirm/index.ts +++ b/libs/auto-confirm/src/index.ts @@ -1,3 +1,5 @@ export * from "./abstractions"; +export * from "./components"; +export * from "./guards"; export * from "./models"; export * from "./services"; diff --git a/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts b/libs/auto-confirm/src/models/auto-confirm-state.model.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts rename to libs/auto-confirm/src/models/auto-confirm-state.model.ts diff --git a/libs/admin-console/src/common/auto-confirm/models/index.ts b/libs/auto-confirm/src/models/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/models/index.ts rename to libs/auto-confirm/src/models/index.ts diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts b/libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts similarity index 72% rename from libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts rename to libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts index 133dac758b4..1d37378b96c 100644 --- a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts +++ b/libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts @@ -1,62 +1,55 @@ import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; -import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; - import { DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserConfirmRequest, -} from "../../organization-user"; +} from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; + import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service"; describe("DefaultAutomaticUserConfirmationService", () => { let service: DefaultAutomaticUserConfirmationService; - let configService: jest.Mocked; - let apiService: jest.Mocked; - let organizationUserService: jest.Mocked; + let configService: MockProxy; + let apiService: MockProxy; + let organizationUserService: MockProxy; let stateProvider: FakeStateProvider; - let organizationService: jest.Mocked; - let organizationUserApiService: jest.Mocked; + let organizationService: MockProxy; + let organizationUserApiService: MockProxy; + let policyService: MockProxy; - const mockUserId = Utils.newGuid() as UserId; - const mockConfirmingUserId = Utils.newGuid() as UserId; - const mockOrganizationId = Utils.newGuid() as OrganizationId; + const mockUserId = newGuid() as UserId; + const mockConfirmingUserId = newGuid() as UserId; + const mockOrganizationId = newGuid() as OrganizationId; let mockOrganization: Organization; beforeEach(() => { - configService = { - getFeatureFlag$: jest.fn(), - } as any; - - apiService = { - getUserPublicKey: jest.fn(), - } as any; - - organizationUserService = { - buildConfirmRequest: jest.fn(), - } as any; - + configService = mock(); + apiService = mock(); + organizationUserService = mock(); stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId)); - - organizationService = { - organizations$: jest.fn(), - } as any; - - organizationUserApiService = { - postOrganizationUserConfirm: jest.fn(), - } as any; + organizationService = mock(); + organizationUserApiService = mock(); + policyService = mock(); TestBed.configureTestingModule({ providers: [ @@ -70,6 +63,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { useValue: organizationService, }, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: PolicyService, useValue: policyService }, ], }); @@ -80,9 +74,13 @@ describe("DefaultAutomaticUserConfirmationService", () => { stateProvider, organizationService, organizationUserApiService, + policyService, ); - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = true; @@ -180,7 +178,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { }); it("should preserve other user configurations when updating", async () => { - const otherUserId = Utils.newGuid() as UserId; + const otherUserId = newGuid() as UserId; const otherConfig = new AutoConfirmState(); otherConfig.enabled = true; @@ -209,12 +207,13 @@ describe("DefaultAutomaticUserConfirmationService", () => { beforeEach(() => { const organizations$ = new BehaviorSubject([mockOrganization]); organizationService.organizations$.mockReturnValue(organizations$); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); }); it("should return true when feature flag is enabled and organization allows management", async () => { configService.getFeatureFlag$.mockReturnValue(of(true)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(true); @@ -223,7 +222,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { it("should return false when feature flag is disabled", async () => { configService.getFeatureFlag$.mockReturnValue(of(false)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -233,7 +232,10 @@ describe("DefaultAutomaticUserConfirmationService", () => { configService.getFeatureFlag$.mockReturnValue(of(true)); // Create organization without manageUsers permission - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = true; const permissions = new PermissionsApi(); @@ -244,7 +246,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([orgWithoutManageUsers]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -254,7 +256,10 @@ describe("DefaultAutomaticUserConfirmationService", () => { configService.getFeatureFlag$.mockReturnValue(of(true)); // Create organization without useAutomaticUserConfirmation - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = false; const permissions = new PermissionsApi(); @@ -265,7 +270,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([orgWithoutAutoConfirm]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -277,7 +282,31 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when the user is not a member of any organizations", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization where user is not a member + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: false, + isProviderUser: false, + }); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + const orgWhereNotMember = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWhereNotMember]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -286,11 +315,58 @@ describe("DefaultAutomaticUserConfirmationService", () => { it("should use the correct feature flag", async () => { configService.getFeatureFlag$.mockReturnValue(of(true)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); await firstValueFrom(canManage$); expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm); }); + + it("should return false when policy does not apply to user", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return true when policy applies to user", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(true); + }); + + it("should check policy with correct PolicyType and userId", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + await firstValueFrom(canManage$); + + expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith( + PolicyType.AutoConfirm, + mockUserId, + ); + }); + + it("should return false when feature flag is enabled but policy does not apply", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith( + PolicyType.AutoConfirm, + mockUserId, + ); + }); }); describe("autoConfirmUser", () => { @@ -305,8 +381,11 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([mockOrganization]); organizationService.organizations$.mockReturnValue(organizations$); configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); - apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + apiService.getUserPublicKey.mockResolvedValue({ + publicKey: mockPublicKey, + } as UserKeyResponse); jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray); organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest)); organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts b/libs/auto-confirm/src/services/default-auto-confirm.service.ts similarity index 75% rename from libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts rename to libs/auto-confirm/src/services/default-auto-confirm.service.ts index d6c435b84a3..109ccb6c9db 100644 --- a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts +++ b/libs/auto-confirm/src/services/default-auto-confirm.service.ts @@ -1,17 +1,20 @@ import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { getById } from "@bitwarden/common/platform/misc"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { StateProvider } from "@bitwarden/state"; import { UserId } from "@bitwarden/user-core"; -import { OrganizationUserApiService, OrganizationUserService } from "../../organization-user"; import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction"; import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; @@ -23,6 +26,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon private stateProvider: StateProvider, private organizationService: InternalOrganizationServiceAbstraction, private organizationUserApiService: OrganizationUserApiService, + private policyService: PolicyService, ) {} private autoConfirmState(userId: UserId) { return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE); @@ -43,15 +47,19 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon }); } - canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable { + canManageAutoConfirm$(userId: UserId): Observable { return combineLatest([ this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - this.organizationService.organizations$(userId).pipe(getById(organizationId)), + this.organizationService + .organizations$(userId) + // auto-confirm does not allow the user to be part of any other organization (even if admin or owner) + // so we can assume that the first organization is the relevant one. + .pipe(map((organizations) => organizations[0])), + this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), ]).pipe( map( - ([enabled, organization]) => - (enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ?? - false, + ([enabled, organization, policyEnabled]) => + enabled && policyEnabled && (organization?.canManageAutoConfirm ?? false), ), ); } @@ -62,7 +70,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon organization: Organization, ): Promise { await firstValueFrom( - this.canManageAutoConfirm$(userId, organization.id).pipe( + this.canManageAutoConfirm$(userId).pipe( map((canManage) => { if (!canManage) { throw new Error("Cannot automatically confirm user (insufficient permissions)"); diff --git a/libs/admin-console/src/common/auto-confirm/services/index.ts b/libs/auto-confirm/src/services/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/services/index.ts rename to libs/auto-confirm/src/services/index.ts diff --git a/libs/auto-confirm/test.setup.ts b/libs/auto-confirm/test.setup.ts new file mode 100644 index 00000000000..5c248668a6d --- /dev/null +++ b/libs/auto-confirm/test.setup.ts @@ -0,0 +1,23 @@ +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); diff --git a/libs/auto-confirm/tsconfig.eslint.json b/libs/auto-confirm/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/auto-confirm/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/auto-confirm/tsconfig.json b/libs/auto-confirm/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/auto-confirm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/auto-confirm/tsconfig.lib.json b/libs/auto-confirm/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/auto-confirm/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/auto-confirm/tsconfig.spec.json b/libs/auto-confirm/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/auto-confirm/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 54d2f93ac03..d1181343549 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -75,8 +75,8 @@ export function canAccessEmergencyAccess( ) { return combineLatest([ configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - policyService.policiesByType$(PolicyType.AutoConfirm, userId), - ]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled))); + policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), + ]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser))); } /** diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 13c7a48e6c4..2991ffb7caa 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -383,6 +383,13 @@ export class Organization { return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null; } + /** + * Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead. + **/ + get canManageAutoConfirm() { + return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation; + } + static fromJSON(json: Jsonify) { if (json == null) { return null; diff --git a/package-lock.json b/package-lock.json index 78b9dce23db..eec3487b6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -513,6 +513,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/auto-confirm": { + "name": "@bitwarden/auto-confirm", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/billing": { "name": "@bitwarden/billing", "version": "0.0.0", @@ -4956,6 +4961,10 @@ "resolved": "libs/auth", "link": true }, + "node_modules/@bitwarden/auto-confirm": { + "resolved": "libs/auto-confirm", + "link": true + }, "node_modules/@bitwarden/billing": { "resolved": "libs/billing", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index d91e8cb9890..68498cfae01 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,7 @@ "@bitwarden/assets/svg": ["./libs/assets/src/svg/index.ts"], "@bitwarden/auth/angular": ["./libs/auth/src/angular"], "@bitwarden/auth/common": ["./libs/auth/src/common"], + "@bitwarden/auto-confirm": ["libs/auto-confirm/src/index.ts"], "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], "@bitwarden/browser/*": ["./apps/browser/src/*"], From 788c5d1d8af3afaf26001f3a03a669adcb60c6a4 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 7 Jan 2026 16:08:13 -0500 Subject: [PATCH 050/101] [PM-29906] SM Discount Defect (#18147) * fix(billing): Update logic for subscriptionLineItems * billing(fix): correct display logic from secretsManager to passwordManager --- ...nization-subscription-cloud.component.html | 20 ++++++++++++------- ...ganization-subscription-cloud.component.ts | 15 ++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 860f80eb346..4858deabec6 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -40,21 +40,27 @@ {{ i.amount | currency: "$" }} - + {{ "freeForOneYear" | i18n }}
- {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} + {{ i.quantity * i.amount | currency: "$" }} / + {{ i.interval | i18n }} {{ - calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$" - }} - / {{ "year" | i18n }}{{ i.quantity * i.originalAmount | currency: "$" }} / + {{ "year" | i18n }}
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index de5d71cce5e..323a190fe1c 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -19,11 +19,9 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -82,9 +80,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private organizationApiService: OrganizationApiServiceAbstraction, private route: ActivatedRoute, private dialogService: DialogService, - private configService: ConfigService, private toastService: ToastService, - private billingApiService: BillingApiServiceAbstraction, private organizationUserApiService: OrganizationUserApiService, ) {} @@ -218,6 +214,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy get subscriptionLineItems() { return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({ name: lineItem.name, + originalAmount: lineItem.amount, amount: this.discountPrice(lineItem.amount, lineItem.productId), quantity: lineItem.quantity, interval: lineItem.interval, @@ -406,12 +403,16 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone"; const appliesToProduct = this.sub?.subscription?.items?.some((item) => - this.sub?.customerDiscount?.appliesTo?.includes(item.productId), + this.discountAppliesToProduct(item.productId), ) ?? false; return isSmStandalone && appliesToProduct; } + discountAppliesToProduct(productId: string): boolean { + return this.sub?.customerDiscount?.appliesTo?.includes(productId) ?? false; + } + closeChangePlan() { this.showChangePlan = false; } @@ -438,10 +439,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy await this.load(); } - calculateTotalAppliedDiscount(total: number) { - return total / (1 - this.customerDiscount?.percentOff / 100); - } - adjustStorage = (add: boolean) => { return async () => { const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, { From ca015515e2381a03d6330ab92608dc5f3bdb74d6 Mon Sep 17 00:00:00 2001 From: brandonbiete Date: Wed, 7 Jan 2026 17:08:14 -0500 Subject: [PATCH 051/101] [BRE-1474] Add GPG signing and automated PRs to repository-management workflow (#18254) * Update repository-management workflow for RC branch rulesets Add GPG signing and PR-based workflow to comply with upcoming RC branch protection rules. Version bumps now create PRs with signed commits instead of pushing directly to branches. * Fix linter issues in workflow Use environment variables for GPG secrets to prevent template injection. Update github-script to v8.0.0 to match other workflows in repo. --- .github/workflows/repository-management.yml | 76 +++++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index b2edf0171db..79f3335313e 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -71,6 +71,8 @@ jobs: version_web: ${{ steps.set-final-version-output.outputs.version_web }} permissions: id-token: write + contents: write + pull-requests: write steps: - name: Validate version input format @@ -93,6 +95,13 @@ jobs: keyvault: gh-org-bitwarden secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + - name: Retrieve GPG secrets + id: retrieve-gpg-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" + - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -102,7 +111,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - permission-contents: write # for committing and pushing to current branch + permission-contents: write # for creating, committing to, and pushing new branches + permission-pull-requests: write # for generating pull requests - name: Check out branch uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -113,8 +123,20 @@ jobs: - name: Configure Git run: | - git config --local user.email "actions@github.com" - git config --local user.name "Github Actions" + git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" + git config --local user.name "bitwarden-devops-bot" + + - name: Setup GPG signing + env: + GPG_PRIVATE_KEY: ${{ steps.retrieve-gpg-secrets.outputs.github-gpg-private-key }} + GPG_PASSPHRASE: ${{ steps.retrieve-gpg-secrets.outputs.github-gpg-private-key-passphrase }} + run: | + echo "$GPG_PRIVATE_KEY" | gpg --import --batch + GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format=long | grep -o "rsa[0-9]\+/[A-F0-9]\+" | head -n1 | cut -d'/' -f2) + git config --local user.signingkey "$GPG_KEY_ID" + git config --local commit.gpgsign true + export GPG_TTY=$(tty) + echo "test" | gpg --clearsign --pinentry-mode=loopback --passphrase "$GPG_PASSPHRASE" > /dev/null 2>&1 ######################## # VERSION BUMP SECTION # @@ -426,13 +448,53 @@ jobs: echo "No changes to commit!"; fi - - name: Commit files + - name: Create version bump branch if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: git commit -m "Bumped client version(s)" -a + run: | + BRANCH_NAME="version-bump-$(date +%s)" + git checkout -b "$BRANCH_NAME" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Push changes + - name: Commit version bumps with GPG signature if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: git push + run: | + git commit -m "Bumped client version(s)" -a + + - name: Push version bump branch + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + run: | + git push --set-upstream origin "$BRANCH_NAME" + + - name: Create Pull Request for version bump + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }} + VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }} + VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }} + VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const versions = []; + if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`); + if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`); + if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`); + if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`); + + const body = versions.length > 0 + ? `Automated version bump:\n\n${versions.join('\n')}` + : 'Automated version bump'; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Bumped client version(s)', + body: body, + head: process.env.BRANCH_NAME, + base: context.ref.replace('refs/heads/', '') + }); + console.log(`Created PR #${pr.number}: ${pr.html_url}`); cut_branch: name: Cut branch From 843913bdee09496fa29d50150afde52a4c00f480 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:33:21 +0000 Subject: [PATCH 052/101] [deps]: Update GitHub Artifact Actions (#17994) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .github/workflows/build-browser.yml | 10 ++-- .github/workflows/build-cli.yml | 12 ++-- .github/workflows/build-desktop.yml | 86 ++++++++++++++--------------- .github/workflows/build-web.yml | 2 +- .github/workflows/test.yml | 8 +-- 5 files changed, 59 insertions(+), 59 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index b5859516eaa..7614fdba396 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -193,7 +193,7 @@ jobs: zip -r browser-source.zip browser-source - name: Upload browser source - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip @@ -272,7 +272,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip @@ -336,7 +336,7 @@ jobs: working-directory: browser-source/apps/browser - name: Upload extension artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }} @@ -349,7 +349,7 @@ jobs: - name: Upload dev extension artifact if: ${{ matrix.browser.archive_name_dev != '' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name_dev }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name_dev }} @@ -523,7 +523,7 @@ jobs: ls -la - name: Upload Safari artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 704a9810b27..d0abe8e12e7 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -268,7 +268,7 @@ jobs: fi - name: Upload unix zip asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip @@ -482,7 +482,7 @@ jobs: } - name: Upload windows zip asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip @@ -490,7 +490,7 @@ jobs: - name: Upload Chocolatey asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg path: apps/cli/dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg @@ -503,7 +503,7 @@ jobs: - name: Upload NPM Build Directory asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip path: apps/cli/bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip @@ -535,7 +535,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap @@ -572,7 +572,7 @@ jobs: run: sudo snap remove bw - name: Upload snap asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 40185f5b700..6b652149d8d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -261,42 +261,42 @@ jobs: run: npm run dist:lin - name: Upload tar.gz artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_x64.tar.gz path: apps/desktop/dist/bitwarden_desktop_x64.tar.gz if-no-files-found: error - name: Upload .deb artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - name: Upload .snap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml @@ -309,7 +309,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: com.bitwarden.desktop.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -437,14 +437,14 @@ jobs: run: npm run dist:lin:arm64 - name: Upload .snap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap if-no-files-found: error - name: Upload tar.gz artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.tar.gz path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz @@ -457,7 +457,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: com.bitwarden.desktop-arm64.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -630,7 +630,7 @@ jobs: -NewName bitwarden-$env:_PACKAGE_VERSION-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -638,7 +638,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -646,7 +646,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -654,7 +654,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -662,7 +662,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -670,7 +670,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx @@ -678,7 +678,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -686,7 +686,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -694,7 +694,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -702,7 +702,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -710,7 +710,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -718,7 +718,7 @@ jobs: - name: Upload nupkg artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg @@ -726,7 +726,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml @@ -883,7 +883,7 @@ jobs: -NewName latest-beta.yml - name: Upload portable exe artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -891,7 +891,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -899,7 +899,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -907,7 +907,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -915,7 +915,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -923,7 +923,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx @@ -931,7 +931,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -939,7 +939,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -947,7 +947,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -955,7 +955,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -963,7 +963,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -971,7 +971,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: latest-beta.yml path: apps/desktop/dist/nsis-web/latest-beta.yml @@ -1429,7 +1429,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1462,28 +1462,28 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml @@ -1712,7 +1712,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1755,14 +1755,14 @@ jobs: $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json - name: Upload MacOS App Store build number artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos-build-number.json path: apps/desktop/dist/macos-build-number.json if-no-files-found: error - name: Upload .pkg artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 7d302fb453b..24a8df084a2 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -307,7 +307,7 @@ jobs: zip -r web-$_VERSION-${{ matrix.artifact_name }}.zip build - name: Upload ${{ matrix.artifact_name }} artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip path: apps/web/web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9738fe6175..e8b4dc9f760 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,7 +74,7 @@ jobs: uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 - name: Upload test coverage - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: jest-coverage path: ./coverage/lcov.info @@ -160,7 +160,7 @@ jobs: run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage - name: Upload test coverage - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native/lcov.info @@ -178,13 +178,13 @@ jobs: persist-credentials: false - name: Download jest coverage - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native From 66252d7c10e29633b2f6c47bef5d7b8b0f9f8069 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:12:17 +0000 Subject: [PATCH 053/101] [deps]: Update Minor github-actions updates (#17621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [deps]: Update Minor github-actions updates * Revert bump --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- .github/workflows/nx.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e8dd654d8ec..ea6894dab8e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -115,7 +115,7 @@ jobs: run: rustup --version - name: Cache cargo registry - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 1e23c31b033..3a7431c07f0 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -36,7 +36,7 @@ jobs: run: npm ci - name: Set Nx SHAs for affected detection - uses: nrwl/nx-set-shas@826660b82addbef3abff5fa871492ebad618c9e1 # v4.3.3 + uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 - name: Run Nx affected tasks continue-on-error: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8b4dc9f760..cf7251b259a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: run: npm test -- --coverage --maxWorkers=3 - name: Report test results - uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1 + uses: dorny/test-reporter@7b7927aa7da8b82e81e755810cb51f39941a2cc7 # v2.2.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results From 5578c94c653e27fbf859baa776bc9c6087226992 Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Thu, 8 Jan 2026 09:27:37 -0500 Subject: [PATCH 054/101] Pm 29917 split the send access component in the web project into multiple components (#18142) * PM 29917 implemented refactor of send access component * PM-29917 refactored to new angular switch syntax * PM-29917 added mark for check --- .../send/send-access/access.component.html | 66 ++------ .../send/send-access/access.component.ts | 155 +++--------------- .../send/send-access/send-auth.component.html | 14 ++ .../send/send-access/send-auth.component.ts | 86 ++++++++++ .../send/send-access/send-view.component.html | 47 ++++++ .../send/send-access/send-view.component.ts | 131 +++++++++++++++ 6 files changed, 319 insertions(+), 180 deletions(-) create mode 100644 apps/web/src/app/tools/send/send-access/send-auth.component.html create mode 100644 apps/web/src/app/tools/send/send-access/send-auth.component.ts create mode 100644 apps/web/src/app/tools/send/send-access/send-view.component.html create mode 100644 apps/web/src/app/tools/send/send-access/send-view.component.ts diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index aec6e2a10b9..b86933410b8 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -1,52 +1,14 @@ -
- - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - - - -
-

{{ "sendAccessUnavailable" | i18n }}

-
-
-

{{ "unexpectedErrorSend" | i18n }}

-
-
-

- {{ send.name }} -

-
- - - - - - - - -

- Expires: {{ expirationDate | date: "medium" }} -

-
-
- -
- - {{ "loading" | i18n }} -
-
-
+@switch (viewState) { + @case ("auth") { + + } + @case ("view") { + + } +} diff --git a/apps/web/src/app/tools/send/send-access/access.component.ts b/apps/web/src/app/tools/send/send-access/access.component.ts index 273f1c8c979..4ea469a0b1c 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.ts +++ b/apps/web/src/app/tools/send/send-access/access.component.ts @@ -1,161 +1,60 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; -import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; -import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; -import { SendAccessFileComponent } from "./send-access-file.component"; -import { SendAccessPasswordComponent } from "./send-access-password.component"; -import { SendAccessTextComponent } from "./send-access-text.component"; +import { SendAuthComponent } from "./send-auth.component"; +import { SendViewComponent } from "./send-view.component"; + +const SendViewState = Object.freeze({ + View: "view", + Auth: "auth", +} as const); +type SendViewState = (typeof SendViewState)[keyof typeof SendViewState]; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access", templateUrl: "access.component.html", - imports: [ - SendAccessFileComponent, - SendAccessTextComponent, - SendAccessPasswordComponent, - SharedModule, - ], + imports: [SendAuthComponent, SendViewComponent, SharedModule], }) export class AccessComponent implements OnInit { - protected send: SendAccessView; - protected sendType = SendType; - protected loading = true; - protected passwordRequired = false; - protected formPromise: Promise; - protected password: string; - protected unavailable = false; - protected error = false; - protected hideEmail = false; - protected decKey: SymmetricCryptoKey; - protected accessRequest: SendAccessRequest; + viewState: SendViewState = SendViewState.View; + id: string; + key: string; - protected formGroup = this.formBuilder.group({}); + sendAccessResponse: SendAccessResponse | null = null; + sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - private id: string; - private key: string; - - constructor( - private cryptoFunctionService: CryptoFunctionService, - private route: ActivatedRoute, - private keyService: KeyService, - private sendApiService: SendApiService, - private toastService: ToastService, - private i18nService: I18nService, - private layoutWrapperDataService: AnonLayoutWrapperDataService, - protected formBuilder: FormBuilder, - ) {} - - protected get expirationDate() { - if (this.send == null || this.send.expirationDate == null) { - return null; - } - return this.send.expirationDate; - } - - protected get creatorIdentifier() { - if (this.send == null || this.send.creatorIdentifier == null) { - return null; - } - return this.send.creatorIdentifier; - } + constructor(private route: ActivatedRoute) {} async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.params.subscribe(async (params) => { this.id = params.sendId; this.key = params.key; - if (this.key == null || this.id == null) { - return; + + if (this.id && this.key) { + this.viewState = SendViewState.View; + this.sendAccessResponse = null; + this.sendAccessRequest = new SendAccessRequest(); } - await this.load(); }); } - protected load = async () => { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - try { - const keyArray = Utils.fromUrlB64ToArray(this.key); - this.accessRequest = new SendAccessRequest(); - if (this.password != null) { - const passwordHash = await this.cryptoFunctionService.pbkdf2( - this.password, - keyArray, - "sha256", - SEND_KDF_ITERATIONS, - ); - this.accessRequest.password = Utils.fromBufferToB64(passwordHash); - } - let sendResponse: SendAccessResponse = null; - if (this.loading) { - sendResponse = await this.sendApiService.postSendAccess(this.id, this.accessRequest); - } else { - this.formPromise = this.sendApiService.postSendAccess(this.id, this.accessRequest); - sendResponse = await this.formPromise; - } - this.passwordRequired = false; - const sendAccess = new SendAccess(sendResponse); - this.decKey = await this.keyService.makeSendKey(keyArray); - this.send = await sendAccess.decrypt(this.decKey); - } catch (e) { - if (e instanceof ErrorResponse) { - if (e.statusCode === 401) { - this.passwordRequired = true; - } else if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: e.message, - }); - } else { - this.error = true; - } - } else { - this.error = true; - } - } - this.loading = false; - this.hideEmail = - this.creatorIdentifier == null && - !this.passwordRequired && - !this.loading && - !this.unavailable; + onAuthRequired() { + this.viewState = SendViewState.Auth; + } - if (this.creatorIdentifier != null) { - this.layoutWrapperDataService.setAnonLayoutWrapperData({ - pageSubtitle: { - key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], - }, - }); - } - }; - - protected setPassword(password: string) { - this.password = password; + onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + this.sendAccessResponse = event.response; + this.sendAccessRequest = event.request; + this.viewState = SendViewState.View; } } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.html b/apps/web/src/app/tools/send/send-access/send-auth.component.html new file mode 100644 index 00000000000..21a6de50ba8 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.html @@ -0,0 +1,14 @@ +
+
+

{{ "sendAccessUnavailable" | i18n }}

+
+
+

{{ "unexpectedErrorSend" | i18n }}

+
+ + +
diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts new file mode 100644 index 00000000000..b360044a8b6 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +import { SendAccessPasswordComponent } from "./send-access-password.component"; + +@Component({ + selector: "app-send-auth", + templateUrl: "send-auth.component.html", + imports: [SendAccessPasswordComponent, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAuthComponent { + readonly id = input.required(); + readonly key = input.required(); + + accessGranted = output<{ + response: SendAccessResponse; + request: SendAccessRequest; + }>(); + + loading = false; + error = false; + unavailable = false; + password?: string; + + private accessRequest!: SendAccessRequest; + + constructor( + private cryptoFunctionService: CryptoFunctionService, + private sendApiService: SendApiService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + async onSubmit(password: string) { + this.password = password; + this.loading = true; + this.error = false; + this.unavailable = false; + + try { + const keyArray = Utils.fromUrlB64ToArray(this.key()); + this.accessRequest = new SendAccessRequest(); + + const passwordHash = await this.cryptoFunctionService.pbkdf2( + this.password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + this.accessRequest.password = Utils.fromBufferToB64(passwordHash); + + const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest); + this.accessGranted.emit({ response: sendResponse, request: this.accessRequest }); + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 404) { + this.unavailable = true; + } else if (e.statusCode === 400) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: e.message, + }); + } else { + this.error = true; + } + } else { + this.error = true; + } + } finally { + this.loading = false; + } + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html new file mode 100644 index 00000000000..dd0b770b261 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -0,0 +1,47 @@ + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }}. + + + +
+

{{ "sendAccessUnavailable" | i18n }}

+
+
+

{{ "unexpectedErrorSend" | i18n }}

+
+
+

+ {{ send.name }} +

+
+ + + + + + + + +

+ Expires: {{ expirationDate | date: "medium" }} +

+
+
+ +
+ + {{ "loading" | i18n }} +
+
diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts new file mode 100644 index 00000000000..0397575f021 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -0,0 +1,131 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + input, + OnInit, + output, +} from "@angular/core"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../shared"; + +import { SendAccessFileComponent } from "./send-access-file.component"; +import { SendAccessTextComponent } from "./send-access-text.component"; + +@Component({ + selector: "app-send-view", + templateUrl: "send-view.component.html", + imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendViewComponent implements OnInit { + readonly id = input.required(); + readonly key = input.required(); + readonly sendResponse = input(null); + readonly accessRequest = input(new SendAccessRequest()); + + authRequired = output(); + + send: SendAccessView | null = null; + sendType = SendType; + loading = true; + unavailable = false; + error = false; + hideEmail = false; + decKey!: SymmetricCryptoKey; + + constructor( + private keyService: KeyService, + private sendApiService: SendApiService, + private toastService: ToastService, + private i18nService: I18nService, + private layoutWrapperDataService: AnonLayoutWrapperDataService, + private cdRef: ChangeDetectorRef, + ) {} + + get expirationDate() { + if (this.send == null || this.send.expirationDate == null) { + return null; + } + return this.send.expirationDate; + } + + get creatorIdentifier() { + if (this.send == null || this.send.creatorIdentifier == null) { + return null; + } + return this.send.creatorIdentifier; + } + + async ngOnInit() { + await this.load(); + } + + private async load() { + this.unavailable = false; + this.error = false; + this.hideEmail = false; + this.loading = true; + + let response = this.sendResponse(); + + try { + if (!response) { + response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + } + + const keyArray = Utils.fromUrlB64ToArray(this.key()); + const sendAccess = new SendAccess(response); + this.decKey = await this.keyService.makeSendKey(keyArray); + this.send = await sendAccess.decrypt(this.decKey); + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 401) { + this.authRequired.emit(); + } else if (e.statusCode === 404) { + this.unavailable = true; + } else if (e.statusCode === 400) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: e.message, + }); + } else { + this.error = true; + } + } else { + this.error = true; + } + } + + this.loading = false; + this.hideEmail = + this.creatorIdentifier == null && !this.loading && !this.unavailable && !response; + + this.hideEmail = this.send != null && this.creatorIdentifier == null; + + if (this.creatorIdentifier != null) { + this.layoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: { + key: "sendAccessCreatorIdentifier", + placeholders: [this.creatorIdentifier], + }, + }); + } + + this.cdRef.markForCheck(); + } +} From 95235a2b7b07c5e2cddecf69f4053d51d57a5166 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 8 Jan 2026 09:59:27 -0500 Subject: [PATCH 055/101] [PM-30526] remove archive option from item more options in AC (#18255) --- .../vault/components/vault-items/vault-cipher-row.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index a723f1e942b..e437537b1cc 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -144,8 +144,9 @@ export class VaultCipherRowComponent implements OnInit } } + // Archive button will not show in Admin Console protected get showArchiveButton() { - if (!this.archiveEnabled()) { + if (!this.archiveEnabled() || this.viewingOrgVault) { return false; } From 0396b4e054fff25fd046a346390282b1ff300aa3 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 8 Jan 2026 16:09:52 +0100 Subject: [PATCH 056/101] Add CSV export functionality to organization members page (#17342) * Add CSV export functionality to organization members page * Remove unnecessary async from getMemberExport method * Changed button position and style * fixed button alignment * updates based on feedback from product design * refactor, cleanup * fix DI * add default to user status pipe * add missing i18n key * copy update * remove redundant copy --------- Co-authored-by: Brandon --- .../organizations/members/index.ts | 1 + .../members/members.component.html | 45 ++++-- .../members/members.component.ts | 41 ++++- .../organizations/members/members.module.ts | 5 + .../organizations/members/pipes/index.ts | 1 + .../members/pipes/user-status.pipe.spec.ts | 47 ++++++ .../members/pipes/user-status.pipe.ts | 30 ++++ .../organizations/members/services/index.ts | 1 + .../members/services/member-export/index.ts | 2 + .../member-export.service.spec.ts | 151 ++++++++++++++++++ .../member-export/member-export.service.ts | 49 ++++++ .../services/member-export/member.export.ts | 43 +++++ .../event-export/event-export.service.ts | 22 +-- apps/web/src/locales/en/messages.json | 15 ++ 14 files changed, 416 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/pipes/index.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts diff --git a/apps/web/src/app/admin-console/organizations/members/index.ts b/apps/web/src/app/admin-console/organizations/members/index.ts index 95bd8baf7c7..7026b9bb6ac 100644 --- a/apps/web/src/app/admin-console/organizations/members/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/index.ts @@ -1 +1,2 @@ export * from "./members.module"; +export * from "./pipes"; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 84e5c33d20d..921004e315d 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -102,15 +102,25 @@ {{ (organization.useGroups ? "groups" : "collections") | i18n }} {{ "role" | i18n }} {{ "policies" | i18n }} - - + +
+ + +
@@ -352,13 +362,16 @@ - +
+
+ +
diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 51a2a6dafc0..e57cf54c180 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -35,6 +35,7 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -55,7 +56,11 @@ import { OrganizationUserView } from "../core/views/organization-user.view"; import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; -import { MemberDialogManagerService, OrganizationMembersService } from "./services"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; import { MemberActionsService, @@ -119,6 +124,8 @@ export class MembersComponent extends BaseMembersComponent private policyService: PolicyService, private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, + private memberExportService: MemberExportService, + private fileDownloadService: FileDownloadService, private configService: ConfigService, private environmentService: EnvironmentService, ) { @@ -593,4 +600,36 @@ export class MembersComponent extends BaseMembersComponent .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } + + exportMembers = async (): Promise => { + try { + const members = this.dataSource.data; + if (!members || members.length === 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noMembersToExport"), + }); + return; + } + + const csvData = this.memberExportService.getMemberExport(members); + const fileName = this.memberExportService.getFileName("org-members"); + + this.fileDownloadService.download({ + fileName: fileName, + blobData: csvData, + blobOptions: { type: "text/plain" }, + }); + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("dataExportSuccess"), + }); + } catch (e) { + this.validationService.showError(e); + this.logService.error(`Failed to export members: ${e}`); + } + }; } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 3b233932ed3..65625cfd247 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; import { MembersRoutingModule } from "./members-routing.module"; import { MembersComponent } from "./members.component"; +import { UserStatusPipe } from "./pipes"; import { OrganizationMembersService, MemberActionsService, MemberDialogManagerService, + MemberExportService, } from "./services"; @NgModule({ @@ -45,12 +47,15 @@ import { BulkStatusComponent, MembersComponent, BulkDeleteDialogComponent, + UserStatusPipe, ], providers: [ OrganizationMembersService, MemberActionsService, BillingConstraintService, MemberDialogManagerService, + MemberExportService, + UserStatusPipe, ], }) export class MembersModule {} diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/index.ts b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts new file mode 100644 index 00000000000..67c485ed361 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts @@ -0,0 +1 @@ +export * from "./user-status.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts new file mode 100644 index 00000000000..3fd05c8a2e8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts @@ -0,0 +1,47 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { UserStatusPipe } from "./user-status.pipe"; + +describe("UserStatusPipe", () => { + let pipe: UserStatusPipe; + let i18nService: MockProxy; + + beforeEach(() => { + i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + pipe = new UserStatusPipe(i18nService); + }); + + it("transforms OrganizationUserStatusType.Invited to 'invited'", () => { + expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited"); + expect(i18nService.t).toHaveBeenCalledWith("invited"); + }); + + it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => { + expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted"); + expect(i18nService.t).toHaveBeenCalledWith("accepted"); + }); + + it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => { + expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed"); + expect(i18nService.t).toHaveBeenCalledWith("confirmed"); + }); + + it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => { + expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked"); + expect(i18nService.t).toHaveBeenCalledWith("revoked"); + }); + + it("transforms null to 'unknown'", () => { + expect(pipe.transform(null)).toBe("unknown"); + expect(i18nService.t).toHaveBeenCalledWith("unknown"); + }); + + it("transforms undefined to 'unknown'", () => { + expect(pipe.transform(undefined)).toBe("unknown"); + expect(i18nService.t).toHaveBeenCalledWith("unknown"); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts new file mode 100644 index 00000000000..81590616027 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts @@ -0,0 +1,30 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +@Pipe({ + name: "userStatus", + standalone: false, +}) +export class UserStatusPipe implements PipeTransform { + constructor(private i18nService: I18nService) {} + + transform(value?: OrganizationUserStatusType): string { + if (value == null) { + return this.i18nService.t("unknown"); + } + switch (value) { + case OrganizationUserStatusType.Invited: + return this.i18nService.t("invited"); + case OrganizationUserStatusType.Accepted: + return this.i18nService.t("accepted"); + case OrganizationUserStatusType.Confirmed: + return this.i18nService.t("confirmed"); + case OrganizationUserStatusType.Revoked: + return this.i18nService.t("revoked"); + default: + return this.i18nService.t("unknown"); + } + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts index baaa33eeae9..fd6b5816513 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -1,4 +1,5 @@ export { OrganizationMembersService } from "./organization-members-service/organization-members.service"; export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; +export { MemberExportService } from "./member-export"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts new file mode 100644 index 00000000000..acd36a91683 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts @@ -0,0 +1,2 @@ +export * from "./member.export"; +export * from "./member-export.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts new file mode 100644 index 00000000000..1e229b95d24 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from "@angular/core/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { + OrganizationUserStatusType, + OrganizationUserType, +} from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +import { MemberExportService } from "./member-export.service"; + +describe("MemberExportService", () => { + let service: MemberExportService; + let i18nService: MockProxy; + + beforeEach(() => { + i18nService = mock(); + + // Setup common i18n translations + i18nService.t.mockImplementation((key: string) => { + const translations: Record = { + // Column headers + email: "Email", + name: "Name", + status: "Status", + role: "Role", + twoStepLogin: "Two-step Login", + accountRecovery: "Account Recovery", + secretsManager: "Secrets Manager", + groups: "Groups", + // Status values + invited: "Invited", + accepted: "Accepted", + confirmed: "Confirmed", + revoked: "Revoked", + // Role values + owner: "Owner", + admin: "Admin", + user: "User", + custom: "Custom", + // Boolean states + enabled: "Enabled", + disabled: "Disabled", + enrolled: "Enrolled", + notEnrolled: "Not Enrolled", + }; + return translations[key] || key; + }); + + TestBed.configureTestingModule({ + providers: [ + MemberExportService, + { provide: I18nService, useValue: i18nService }, + UserTypePipe, + UserStatusPipe, + ], + }); + + service = TestBed.inject(MemberExportService); + }); + + describe("getMemberExport", () => { + it("should export members with all fields populated", () => { + const members: OrganizationUserView[] = [ + { + email: "user1@example.com", + name: "User One", + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.Admin, + twoFactorEnabled: true, + resetPasswordEnrolled: true, + accessSecretsManager: true, + groupNames: ["Group A", "Group B"], + } as OrganizationUserView, + { + email: "user2@example.com", + name: "User Two", + status: OrganizationUserStatusType.Invited, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: ["Group C"], + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery"); + expect(csvData).toContain("user1@example.com"); + expect(csvData).toContain("User One"); + expect(csvData).toContain("Confirmed"); + expect(csvData).toContain("Admin"); + expect(csvData).toContain("user2@example.com"); + expect(csvData).toContain("User Two"); + expect(csvData).toContain("Invited"); + }); + + it("should handle members with null name", () => { + const members: OrganizationUserView[] = [ + { + email: "user@example.com", + name: null, + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: [], + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("user@example.com"); + // Empty name is represented as an empty field in CSV + expect(csvData).toContain("user@example.com,,Confirmed"); + }); + + it("should handle members with no groups", () => { + const members: OrganizationUserView[] = [ + { + email: "user@example.com", + name: "User", + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: null, + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("user@example.com"); + expect(csvData).toBeDefined(); + }); + + it("should handle empty members array", () => { + const csvData = service.getMemberExport([]); + + // When array is empty, papaparse returns an empty string + expect(csvData).toBe(""); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts new file mode 100644 index 00000000000..c00881617a4 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts @@ -0,0 +1,49 @@ +import { inject, Injectable } from "@angular/core"; +import * as papa from "papaparse"; + +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ExportHelper } from "@bitwarden/vault-export-core"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +import { MemberExport } from "./member.export"; + +@Injectable() +export class MemberExportService { + private i18nService = inject(I18nService); + private userTypePipe = inject(UserTypePipe); + private userStatusPipe = inject(UserStatusPipe); + + getMemberExport(members: OrganizationUserView[]): string { + const exportData = members.map((m) => + MemberExport.fromOrganizationUserView( + this.i18nService, + this.userTypePipe, + this.userStatusPipe, + m, + ), + ); + + const headers: string[] = [ + this.i18nService.t("email"), + this.i18nService.t("name"), + this.i18nService.t("status"), + this.i18nService.t("role"), + this.i18nService.t("twoStepLogin"), + this.i18nService.t("accountRecovery"), + this.i18nService.t("secretsManager"), + this.i18nService.t("groups"), + ]; + + return papa.unparse(exportData, { + columns: headers, + header: true, + }); + } + + getFileName(prefix: string | null = null, extension = "csv"): string { + return ExportHelper.getFileName(prefix ?? "", extension); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts new file mode 100644 index 00000000000..262e8ebd9fb --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts @@ -0,0 +1,43 @@ +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +export class MemberExport { + /** + * @param user Organization user to export + * @returns a Record of each column header key, value + * All property members must be a string for export purposes. Null and undefined will appear as + * "null" in a .csv export, therefore an empty string is preferable to a nullish type. + */ + static fromOrganizationUserView( + i18nService: I18nService, + userTypePipe: UserTypePipe, + userStatusPipe: UserStatusPipe, + user: OrganizationUserView, + ): Record { + const result = { + [i18nService.t("email")]: user.email, + [i18nService.t("name")]: user.name ?? "", + [i18nService.t("status")]: userStatusPipe.transform(user.status), + [i18nService.t("role")]: userTypePipe.transform(user.type), + + [i18nService.t("twoStepLogin")]: user.twoFactorEnabled + ? i18nService.t("optionEnabled") + : i18nService.t("disabled"), + + [i18nService.t("accountRecovery")]: user.resetPasswordEnrolled + ? i18nService.t("enrolled") + : i18nService.t("notEnrolled"), + + [i18nService.t("secretsManager")]: user.accessSecretsManager + ? i18nService.t("optionEnabled") + : i18nService.t("disabled"), + + [i18nService.t("groups")]: user.groupNames?.join(", ") ?? "", + }; + + return result; + } +} diff --git a/apps/web/src/app/tools/event-export/event-export.service.ts b/apps/web/src/app/tools/event-export/event-export.service.ts index f39b786b6d1..d888af51edf 100644 --- a/apps/web/src/app/tools/event-export/event-export.service.ts +++ b/apps/web/src/app/tools/event-export/event-export.service.ts @@ -4,6 +4,7 @@ import { Injectable } from "@angular/core"; import * as papa from "papaparse"; import { EventView } from "@bitwarden/common/models/view/event.view"; +import { ExportHelper } from "@bitwarden/vault-export-core"; import { EventExport } from "./event.export"; @@ -16,25 +17,6 @@ export class EventExportService { } getFileName(prefix: string = null, extension = "csv"): string { - const now = new Date(); - const dateString = - now.getFullYear() + - "" + - this.padNumber(now.getMonth() + 1, 2) + - "" + - this.padNumber(now.getDate(), 2) + - this.padNumber(now.getHours(), 2) + - "" + - this.padNumber(now.getMinutes(), 2) + - this.padNumber(now.getSeconds(), 2); - - return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension; - } - - private padNumber(num: number, width: number, padCharacter = "0"): string { - const numString = num.toString(); - return numString.length >= width - ? numString - : new Array(width - numString.length + 1).join(padCharacter) + numString; + return ExportHelper.getFileName(prefix ?? "", extension); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index db30a9d1153..8024de21e56 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, From 4866eaa2ec3950683cd4a878e6c5c86365c4aabb Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:09:13 -0600 Subject: [PATCH 057/101] [PM-23618] Require masterKey on makeUserKey (#17244) --- .../src/abstractions/key.service.ts | 6 ++--- libs/key-management/src/key.service.spec.ts | 26 +++++++++++++++++++ libs/key-management/src/key.service.ts | 14 +++------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index feb4a38ac27..6cf44544422 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -129,11 +129,11 @@ export abstract class KeyService { /** * Generates a new user key * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. - * @throws Error when master key is null and there is no active user - * @param masterKey The user's master key. When null, grabs master key from active user. + * @throws Error when master key is null or undefined. + * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ - abstract makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]>; + abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; /** * Generates a new user key for a V1 user * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index c0af62fe6e9..c0a0ab62347 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -177,6 +177,32 @@ describe("keyService", () => { }); }); + describe("makeUserKey", () => { + test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])( + "throws when the provided masterKey is %s", + async (masterKey) => { + await expect(keyService.makeUserKey(masterKey)).rejects.toThrow("MasterKey is required"); + }, + ); + + it("encrypts the user key with the master key", async () => { + const mockUserKey = makeSymmetricCryptoKey(64); + const mockEncryptedUserKey = makeEncString("encryptedUserKey"); + + keyGenerationService.createKey.mockResolvedValue(mockUserKey); + encryptService.wrapSymmetricKey.mockResolvedValue(mockEncryptedUserKey); + const stretchedMasterKey = new SymmetricCryptoKey(new Uint8Array(64)); + keyGenerationService.stretchKey.mockResolvedValue(stretchedMasterKey); + + const result = await keyService.makeUserKey(makeSymmetricCryptoKey(32)); + + expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockUserKey, stretchedMasterKey); + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(result[0]).toBe(mockUserKey); + expect(result[1]).toBe(mockEncryptedUserKey); + }); + }); + describe("everHadUserKey$", () => { let everHadUserKeyState: FakeSingleUserState; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 621a8135d1e..8cb072a4c2a 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -204,17 +204,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { return (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId))) != null; } - async makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]> { - if (masterKey == null) { - const userId = await firstValueFrom(this.stateProvider.activeUserId$); - if (userId == null) { - throw new Error("No active user id found."); - } - - masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - } - if (masterKey == null) { - throw new Error("No Master Key found."); + async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> { + if (!masterKey) { + throw new Error("MasterKey is required"); } const newUserKey = await this.keyGenerationService.createKey(512); From de2ebc484a9205baae3a5d43c22fb97b2aefc13d Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:33:24 -0600 Subject: [PATCH 058/101] exclude deleted items from at risk check (#18246) --- .../default-cipher-risk.service.spec.ts | 67 +++++++++++++++++++ .../services/default-cipher-risk.service.ts | 4 +- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts index e5231241462..fad5e963113 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts @@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => { expect.any(Object), ); }); + + it("should filter out deleted Login ciphers", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const activeCipher = new CipherView(); + activeCipher.id = mockCipherId1; + activeCipher.type = CipherType.Login; + activeCipher.login = new LoginView(); + activeCipher.login.password = "password1"; + activeCipher.deletedDate = undefined; + + const deletedCipher = new CipherView(); + deletedCipher.id = mockCipherId2; + deletedCipher.type = CipherType.Login; + deletedCipher.login = new LoginView(); + deletedCipher.login.password = "password2"; + deletedCipher.deletedDate = new Date(); + + await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: expect.anything(), + password: "password1", + }), + ], + expect.any(Object), + ); + }); }); describe("buildPasswordReuseMap", () => { @@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => { ]); expect(result).toEqual(mockReuseMap); }); + + it("should exclude deleted ciphers when building password reuse map", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const mockReuseMap = { + password1: 1, + }; + + mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap); + + const activeCipher = new CipherView(); + activeCipher.id = mockCipherId1; + activeCipher.type = CipherType.Login; + activeCipher.login = new LoginView(); + activeCipher.login.password = "password1"; + activeCipher.deletedDate = undefined; + + const deletedCipherWithSamePassword = new CipherView(); + deletedCipherWithSamePassword.id = mockCipherId2; + deletedCipherWithSamePassword.type = CipherType.Login; + deletedCipherWithSamePassword.login = new LoginView(); + deletedCipherWithSamePassword.login.password = "password1"; + deletedCipherWithSamePassword.deletedDate = new Date(); + + const result = await cipherRiskService.buildPasswordReuseMap( + [activeCipher, deletedCipherWithSamePassword], + mockUserId, + ); + + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1" }), + ]); + expect(result).toEqual(mockReuseMap); + }); }); describe("computeCipherRiskForUser", () => { diff --git a/libs/common/src/vault/services/default-cipher-risk.service.ts b/libs/common/src/vault/services/default-cipher-risk.service.ts index 4b4558e5e7a..5f424fdd7a2 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.ts @@ -71,7 +71,6 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { passwordMap, checkExposed, }); - return results[0]; } @@ -103,7 +102,8 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { return ( cipher.type === CipherType.Login && cipher.login?.password != null && - cipher.login.password !== "" + cipher.login.password !== "" && + !cipher.isDeleted ); }) .map( From 0dd4ed702624249c72c90dccf055b049a0d2f443 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:54:39 -0600 Subject: [PATCH 059/101] Use official latest phishing sources from phish.co.za (#18271) --- .../src/dirt/phishing-detection/phishing-resources.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 262d6cf833b..4cd155c8ae3 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -18,8 +18,7 @@ export const PHISHING_RESOURCES: Record Date: Thu, 8 Jan 2026 15:01:03 -0500 Subject: [PATCH 060/101] Added batch encrypt many method and used that in imports (#18266) --- .../abstractions/cipher-encryption.service.ts | 10 +++ .../src/vault/abstractions/cipher.service.ts | 9 +++ .../src/vault/services/cipher.service.ts | 18 ++++++ .../default-cipher-encryption.service.spec.ts | 62 +++++++++++++++++++ .../default-cipher-encryption.service.ts | 38 ++++++++++++ libs/importer/src/services/import.service.ts | 24 ++++--- 6 files changed, 154 insertions(+), 7 deletions(-) diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index fdd42c0acf2..a3b824fd46e 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -20,6 +20,16 @@ export abstract class CipherEncryptionService { */ abstract encrypt(model: CipherView, userId: UserId): Promise; + /** + * Encrypts multiple ciphers using the SDK for the given userId. + * + * @param models The cipher views to encrypt + * @param userId The user ID to initialize the SDK client with + * + * @returns A promise that resolves to an array of encryption contexts + */ + abstract encryptMany(models: CipherView[], userId: UserId): Promise; + /** * Move the cipher to the specified organization by re-encrypting its keys with the organization's key. * The cipher.organizationId will be updated to the new organizationId. diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 0d3a0b99fcb..203984075f7 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + /** + * Encrypts multiple ciphers for the given user. + * + * @param models The cipher views to encrypt + * @param userId The user ID to encrypt for + * + * @returns A promise that resolves to an array of encryption contexts + */ + abstract encryptMany(models: CipherView[], userId: UserId): Promise; abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise; abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise; abstract get(id: string, userId: UserId): Promise; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d25aa62ea3a..2e0adc892e3 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction { } } + async encryptMany(models: CipherView[], userId: UserId): Promise { + const sdkEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); + + if (sdkEncryptionEnabled) { + return await this.cipherEncryptionService.encryptMany(models, userId); + } + + // Fallback to sequential encryption if SDK disabled + const results: EncryptionContext[] = []; + for (const model of models) { + const result = await this.encrypt(model, userId); + results.push(result); + } + return results; + } + async encryptAttachments( attachmentsModel: AttachmentView[], key: SymmetricCryptoKey, diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index f54dfa17a38..a0ca4833b92 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => { }); }); + describe("encryptMany", () => { + it("should encrypt multiple ciphers", async () => { + const cipherView2 = new CipherView(cipherObj); + cipherView2.name = "test-name-2"; + const cipherView3 = new CipherView(cipherObj); + cipherView3.name = "test-name-3"; + + const ciphers = [cipherViewObj, cipherView2, cipherView3]; + + const expectedCipher1: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-1", + } as unknown as Cipher; + + const expectedCipher2: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-2", + } as unknown as Cipher; + + const expectedCipher3: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-3", + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + + jest + .spyOn(Cipher, "fromSdkCipher") + .mockReturnValueOnce(expectedCipher1) + .mockReturnValueOnce(expectedCipher2) + .mockReturnValueOnce(expectedCipher3); + + const results = await cipherEncryptionService.encryptMany(ciphers, userId); + + expect(results).toBeDefined(); + expect(results.length).toBe(3); + expect(results[0].cipher).toEqual(expectedCipher1); + expect(results[1].cipher).toEqual(expectedCipher2); + expect(results[2].cipher).toEqual(expectedCipher3); + + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3); + + expect(results[0].encryptedFor).toBe(userId); + expect(results[1].encryptedFor).toBe(userId); + expect(results[2].encryptedFor).toBe(userId); + }); + + it("should handle empty array", async () => { + const results = await cipherEncryptionService.encryptMany([], userId); + + expect(results).toBeDefined(); + expect(results.length).toBe(0); + expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled(); + }); + }); + describe("encryptCipherForRotation", () => { it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => { mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({ diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index f1b737ed50f..588265846e0 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ); } + async encryptMany(models: CipherView[], userId: UserId): Promise { + if (!models || models.length === 0) { + return []; + } + + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + + const results: EncryptionContext[] = []; + + // TODO: https://bitwarden.atlassian.net/browse/PM-30580 + // Replace this loop with a native SDK encryptMany method for better performance. + for (const model of models) { + const sdkCipherView = this.toSdkCipherView(model, ref.value); + const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView); + + results.push({ + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId, + }); + } + + return results; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to encrypt ciphers in batch: ${error}`); + return EMPTY; + }), + ), + ); + } + async moveToOrganization( model: CipherView, organizationId: OrganizationId, diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 400beae5179..829bd04e994 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -374,10 +374,13 @@ export class ImportService implements ImportServiceAbstraction { private async handleIndividualImport(importResult: ImportResult, userId: UserId) { const request = new ImportCiphersRequest(); - for (let i = 0; i < importResult.ciphers.length; i++) { - const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); - request.ciphers.push(new CipherRequest(c)); + + const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId); + + for (const encryptedCipher of encryptedCiphers) { + request.ciphers.push(new CipherRequest(encryptedCipher)); } + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (importResult.folders != null) { @@ -400,11 +403,18 @@ export class ImportService implements ImportServiceAbstraction { userId: UserId, ) { const request = new ImportOrganizationCiphersRequest(); - for (let i = 0; i < importResult.ciphers.length; i++) { - importResult.ciphers[i].organizationId = organizationId; - const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); - request.ciphers.push(new CipherRequest(c)); + + // Set organization ID on all ciphers before batch encryption + importResult.ciphers.forEach((cipher) => { + cipher.organizationId = organizationId; + }); + + const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId); + + for (const encryptedCipher of encryptedCiphers) { + request.ciphers.push(new CipherRequest(encryptedCipher)); } + if (importResult.collections != null) { for (let i = 0; i < importResult.collections.length; i++) { importResult.collections[i].organizationId = organizationId; From 6579e31374cc96fc0b0d4debea8c7e3a429c0bf6 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 8 Jan 2026 16:16:08 -0500 Subject: [PATCH 061/101] [PM-30537] add tab nav to restart premium link (#18269) --- apps/web/src/app/vault/individual-vault/vault.component.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index df1b727154f..cb5332d07d8 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -43,7 +43,9 @@
{{ "premiumSubscriptionEndedDesc" | i18n }}
- {{ "restartPremium" | i18n }} + + {{ "restartPremium" | i18n }} +
} From eedc36cf3959f8c8da1d350c45697960e16e1e65 Mon Sep 17 00:00:00 2001 From: blackwood Date: Thu, 8 Jan 2026 16:46:13 -0500 Subject: [PATCH 062/101] Revert "Display autofill overlay for zoom.us signin password (#16900)" (#18261) --- .../services/autofill-overlay-content.service.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 2087b0640fb..7ea89e114ab 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1086,15 +1086,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ pageDetails, ) ) { - const hasUsernameField = [...this.formFieldElements.values()].some((field) => - this.inlineMenuFieldQualificationService.isUsernameField(field), - ); - - if (hasUsernameField) { - void this.setQualifiedLoginFillType(autofillFieldData); - } else { - this.setQualifiedAccountCreationFillType(autofillFieldData); - } + this.setQualifiedAccountCreationFillType(autofillFieldData); return false; } From 4aa69a769be8228c4001225c2b448d2a092b6927 Mon Sep 17 00:00:00 2001 From: Zhaolin Liang Date: Fri, 9 Jan 2026 07:19:56 +0800 Subject: [PATCH 063/101] [PM-25402] auto-assign new logins to current folder/collection (#16268) * auto-assign selected collection for new vault items * Ensure a selected collectionId in the vault filter is passed on to a newly created cipher. Fixes #15485 * Assign selected folder and collection when creating a new cipher Added here to prevent a regression whenever we switch over to this componet and deprecate vault-v2.component * account for null folderIds --------- Co-authored-by: Daniel James Smith Co-authored-by: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Co-authored-by: jaasen-livefront --- apps/desktop/src/vault/app/vault-v3/vault.component.ts | 10 +++++----- apps/desktop/src/vault/app/vault/vault-v2.component.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index 21ba7547f8b..a16ef93e230 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -34,7 +34,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -158,7 +158,7 @@ export class VaultComponent cipherId: string | null = null; favorites = false; type: CipherType | null = null; - folderId: string | null = null; + folderId: string | null | undefined = null; collectionId: string | null = null; organizationId: string | null = null; myVaultOnly = false; @@ -980,9 +980,7 @@ export class VaultComponent // clear out organizationId when the user switches to a personal vault filter this.addOrganizationId = null; } - if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { - this.folderId = this.activeFilter.selectedFolderId; - } + this.folderId = this.activeFilter.selectedFolderId; if (this.config == null) { return; @@ -990,7 +988,9 @@ export class VaultComponent this.config.initialValues = { ...this.config.initialValues, + folderId: this.folderId, organizationId: this.addOrganizationId as OrganizationId, + collectionIds: this.addCollectionIds as CollectionId[], }; } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index ade4af928fc..eedcb4dde83 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -45,7 +45,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -172,7 +172,7 @@ export class VaultV2Component cipherId: string | null = null; favorites = false; type: CipherType | null = null; - folderId: string | null = null; + folderId: string | null | undefined = null; collectionId: string | null = null; organizationId: OrganizationId | null = null; myVaultOnly = false; @@ -1016,9 +1016,7 @@ export class VaultV2Component // clear out organizationId when the user switches to a personal vault filter this.addOrganizationId = null; } - if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { - this.folderId = this.activeFilter.selectedFolderId; - } + this.folderId = this.activeFilter.selectedFolderId; if (this.config == null) { return; @@ -1027,6 +1025,8 @@ export class VaultV2Component this.config.initialValues = { ...this.config.initialValues, organizationId: this.addOrganizationId as OrganizationId, + folderId: this.folderId, + collectionIds: this.addCollectionIds as CollectionId[], }; } From 1022d21654fd54223eb460d03e2773aa14e52d8f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:21:32 -0800 Subject: [PATCH 064/101] use custom auth wrapper for at-risk-passwords (#18055) --- apps/browser/src/popup/app-routing.module.ts | 3 ++- .../popup/guards/at-risk-passwords.guard.ts | 26 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 6838d4940ab..1f1d4d25b40 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -86,6 +86,7 @@ import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/v import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { + atRiskPasswordAuthGuard, canAccessAtRiskPasswords, hasAtRiskPasswords, } from "../vault/popup/guards/at-risk-passwords.guard"; @@ -723,7 +724,7 @@ const routes: Routes = [ { path: "at-risk-passwords", component: AtRiskPasswordsComponent, - canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], + canActivate: [atRiskPasswordAuthGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { path: AuthExtensionRoute.AccountSwitcher, diff --git a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts index 03111859165..1b279e1078d 100644 --- a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts +++ b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts @@ -1,7 +1,13 @@ import { inject } from "@angular/core"; -import { CanActivateFn, Router } from "@angular/router"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; import { combineLatest, map, switchMap } from "rxjs"; +import { authGuard } from "@bitwarden/angular/auth/guards"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -9,6 +15,24 @@ import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { ToastService } from "@bitwarden/components"; +/** + * Wrapper around the main auth guard to redirect to login if not authenticated. + * This is necessary because the main auth guard returns false when not authenticated, + * which in a browser context may result in a blank extension page rather than a redirect. + */ +export const atRiskPasswordAuthGuard: CanActivateFn = async ( + route: ActivatedRouteSnapshot, + routerState: RouterStateSnapshot, +) => { + const router = inject(Router); + + const authGuardResponse = await authGuard(route, routerState); + if (authGuardResponse === true) { + return authGuardResponse; + } + return router.createUrlTree(["/login"]); +}; + export const canAccessAtRiskPasswords: CanActivateFn = () => { const accountService = inject(AccountService); const taskService = inject(TaskService); From 95100b6f239c033cda677ffece75e44a07ac1e0b Mon Sep 17 00:00:00 2001 From: Isaac Ivins Date: Fri, 9 Jan 2026 03:41:15 -0500 Subject: [PATCH 065/101] Feature/pm 28788 desktop header UI migration (#18221) Add desktop header component --- apps/desktop/src/app/app-routing.module.ts | 1 + .../header/desktop-header.component.html | 21 +++ .../header/desktop-header.component.spec.ts | 122 ++++++++++++++++++ .../layout/header/desktop-header.component.ts | 47 +++++++ apps/desktop/src/app/layout/header/index.ts | 1 + .../app/tools/send-v2/send-v2.component.html | 59 ++++----- .../tools/send-v2/send-v2.component.spec.ts | 7 + .../app/tools/send-v2/send-v2.component.ts | 2 + .../components/src/header/header.component.ts | 3 + 9 files changed, 229 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/app/layout/header/desktop-header.component.html create mode 100644 apps/desktop/src/app/layout/header/desktop-header.component.spec.ts create mode 100644 apps/desktop/src/app/layout/header/desktop-header.component.ts create mode 100644 apps/desktop/src/app/layout/header/index.ts diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 6077afa8c12..f75f6ccdc20 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -361,6 +361,7 @@ const routes: Routes = [ { path: "new-sends", component: SendV2Component, + data: { pageTitle: { key: "send" } } satisfies RouteDataProperties, }, ], }, diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.html b/apps/desktop/src/app/layout/header/desktop-header.component.html new file mode 100644 index 00000000000..efee5e21d9b --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.html @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts new file mode 100644 index 00000000000..8d3db198887 --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent } from "@bitwarden/components"; + +import { DesktopHeaderComponent } from "./desktop-header.component"; + +describe("DesktopHeaderComponent", () => { + let component: DesktopHeaderComponent; + let fixture: ComponentFixture; + let mockI18nService: ReturnType>; + let mockActivatedRoute: { data: any }; + + beforeEach(async () => { + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => `translated_${key}`); + + mockActivatedRoute = { + data: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [DesktopHeaderComponent, HeaderComponent], + providers: [ + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-header component", () => { + const compiled = fixture.nativeElement; + const headerElement = compiled.querySelector("bit-header"); + + expect(headerElement).toBeTruthy(); + }); + + describe("title resolution", () => { + it("uses title input when provided", () => { + fixture.componentRef.setInput("title", "Direct Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Direct Title"); + }); + + it("uses route data titleId when no direct title provided", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(mockI18nService.t).toHaveBeenCalledWith("sends"); + expect(component["resolvedTitle"]()).toBe("translated_sends"); + }); + + it("returns empty string when no title or route data provided", () => { + mockActivatedRoute.data = of({}); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe(""); + }); + + it("prioritizes direct title over route data", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Override Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Override Title"); + }); + }); + + describe("icon input", () => { + it("accepts icon input", () => { + fixture.componentRef.setInput("icon", "bwi-send"); + fixture.detectChanges(); + + expect(component.icon()).toBe("bwi-send"); + }); + + it("defaults to undefined when no icon provided", () => { + expect(component.icon()).toBeUndefined(); + }); + }); + + describe("content projection", () => { + it("wraps bit-header component for slot pass-through", () => { + const compiled = fixture.nativeElement; + const bitHeader = compiled.querySelector("bit-header"); + + // Verify bit-header exists and can receive projected content + expect(bitHeader).toBeTruthy(); + }); + }); +}); diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.ts b/apps/desktop/src/app/layout/header/desktop-header.component.ts new file mode 100644 index 00000000000..5a837f1ff5a --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { map } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent, BannerModule } from "@bitwarden/components"; + +@Component({ + selector: "app-header", + templateUrl: "./desktop-header.component.html", + imports: [BannerModule, HeaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DesktopHeaderComponent { + private route = inject(ActivatedRoute); + private i18nService = inject(I18nService); + + /** + * Title to display in header (takes precedence over route data) + */ + readonly title = input(); + + /** + * Icon to show before the title + */ + readonly icon = input(); + + private readonly routeData = toSignal( + this.route.data.pipe( + map((params) => ({ + titleId: params["pageTitle"]?.["key"] as string | undefined, + })), + ), + { initialValue: { titleId: undefined } }, + ); + + protected readonly resolvedTitle = computed(() => { + const directTitle = this.title(); + if (directTitle) { + return directTitle; + } + + const titleId = this.routeData().titleId; + return titleId ? this.i18nService.t(titleId) : ""; + }); +} diff --git a/apps/desktop/src/app/layout/header/index.ts b/apps/desktop/src/app/layout/header/index.ts new file mode 100644 index 00000000000..793d90f81e5 --- /dev/null +++ b/apps/desktop/src/app/layout/header/index.ts @@ -0,0 +1 @@ +export { DesktopHeaderComponent } from "./desktop-header.component"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index 659e4be9c5b..05c1332f1e7 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -1,40 +1,31 @@
-
- -
-

{{ "send" | i18n }}

- @if (!disableSend()) { - - } -
- + + + @if (!disableSend()) { + + } + +
-
- - - -
+ + +
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 58c9ce8e0b4..713915e3cf7 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { ActivatedRoute } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; @@ -89,6 +90,12 @@ describe("SendV2Component", () => { }, { provide: MessagingService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, + { + provide: ActivatedRoute, + useValue: { + data: of({}), + }, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 1b2ccb30247..6a44713d309 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -35,6 +35,7 @@ import { } from "@bitwarden/send-ui"; import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; +import { DesktopHeaderComponent } from "../../layout/header"; import { AddEditComponent } from "../send/add-edit.component"; const Action = Object.freeze({ @@ -56,6 +57,7 @@ type Action = (typeof Action)[keyof typeof Action]; AddEditComponent, SendListComponent, NewSendDropdownV2Component, + DesktopHeaderComponent, ], providers: [ { diff --git a/libs/components/src/header/header.component.ts b/libs/components/src/header/header.component.ts index 08cd91ea206..44b0c063d89 100644 --- a/libs/components/src/header/header.component.ts +++ b/libs/components/src/header/header.component.ts @@ -1,8 +1,11 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { TypographyDirective } from "../typography/typography.directive"; + @Component({ selector: "bit-header", templateUrl: "./header.component.html", + imports: [TypographyDirective], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) From bcdf3a52bb5c89537239d6436a1f6054f2c58f9a Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:22:20 +0100 Subject: [PATCH 066/101] Autosync the updated translations (#18276) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/af/messages.json | 4 +-- apps/desktop/src/locales/ar/messages.json | 4 +-- apps/desktop/src/locales/az/messages.json | 4 +-- apps/desktop/src/locales/be/messages.json | 4 +-- apps/desktop/src/locales/bg/messages.json | 4 +-- apps/desktop/src/locales/bn/messages.json | 4 +-- apps/desktop/src/locales/bs/messages.json | 4 +-- apps/desktop/src/locales/ca/messages.json | 4 +-- apps/desktop/src/locales/cs/messages.json | 2 +- apps/desktop/src/locales/cy/messages.json | 4 +-- apps/desktop/src/locales/da/messages.json | 4 +-- apps/desktop/src/locales/de/messages.json | 16 ++++++------ apps/desktop/src/locales/el/messages.json | 4 +-- apps/desktop/src/locales/en_GB/messages.json | 4 +-- apps/desktop/src/locales/en_IN/messages.json | 4 +-- apps/desktop/src/locales/eo/messages.json | 4 +-- apps/desktop/src/locales/es/messages.json | 4 +-- apps/desktop/src/locales/et/messages.json | 4 +-- apps/desktop/src/locales/eu/messages.json | 4 +-- apps/desktop/src/locales/fa/messages.json | 4 +-- apps/desktop/src/locales/fi/messages.json | 4 +-- apps/desktop/src/locales/fil/messages.json | 4 +-- apps/desktop/src/locales/fr/messages.json | 4 +-- apps/desktop/src/locales/gl/messages.json | 4 +-- apps/desktop/src/locales/he/messages.json | 4 +-- apps/desktop/src/locales/hi/messages.json | 4 +-- apps/desktop/src/locales/hr/messages.json | 4 +-- apps/desktop/src/locales/hu/messages.json | 4 +-- apps/desktop/src/locales/id/messages.json | 4 +-- apps/desktop/src/locales/it/messages.json | 26 ++++++++++---------- apps/desktop/src/locales/ja/messages.json | 4 +-- apps/desktop/src/locales/ka/messages.json | 4 +-- apps/desktop/src/locales/km/messages.json | 4 +-- apps/desktop/src/locales/kn/messages.json | 4 +-- apps/desktop/src/locales/ko/messages.json | 4 +-- apps/desktop/src/locales/lt/messages.json | 4 +-- apps/desktop/src/locales/lv/messages.json | 4 +-- apps/desktop/src/locales/me/messages.json | 4 +-- apps/desktop/src/locales/ml/messages.json | 4 +-- apps/desktop/src/locales/mr/messages.json | 4 +-- apps/desktop/src/locales/my/messages.json | 4 +-- apps/desktop/src/locales/nb/messages.json | 4 +-- apps/desktop/src/locales/ne/messages.json | 4 +-- apps/desktop/src/locales/nl/messages.json | 4 +-- apps/desktop/src/locales/nn/messages.json | 4 +-- apps/desktop/src/locales/or/messages.json | 4 +-- apps/desktop/src/locales/pl/messages.json | 4 +-- apps/desktop/src/locales/pt_BR/messages.json | 4 +-- apps/desktop/src/locales/pt_PT/messages.json | 4 +-- apps/desktop/src/locales/ro/messages.json | 4 +-- apps/desktop/src/locales/ru/messages.json | 4 +-- apps/desktop/src/locales/si/messages.json | 4 +-- apps/desktop/src/locales/sk/messages.json | 4 +-- apps/desktop/src/locales/sl/messages.json | 4 +-- apps/desktop/src/locales/sr/messages.json | 4 +-- apps/desktop/src/locales/sv/messages.json | 6 ++--- apps/desktop/src/locales/ta/messages.json | 4 +-- apps/desktop/src/locales/te/messages.json | 4 +-- apps/desktop/src/locales/th/messages.json | 4 +-- apps/desktop/src/locales/tr/messages.json | 4 +-- apps/desktop/src/locales/uk/messages.json | 4 +-- apps/desktop/src/locales/vi/messages.json | 4 +-- apps/desktop/src/locales/zh_CN/messages.json | 4 +-- apps/desktop/src/locales/zh_TW/messages.json | 4 +-- 64 files changed, 145 insertions(+), 145 deletions(-) diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index c879ae0cc70..cb186f31d35 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 77a73a6c0b4..a33c6b301b6 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 31735e88ef1..d3cdbba9e23 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Yazma qısayolu" }, - "editAutotypeShortcutDescription": { - "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və ya Shift və bir hərf." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Yararsız qısayol" diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 6bb3bf31013..4f441d20781 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index aceff28455f..10702ea4aa9 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Комбинация за въвеждане" }, - "editAutotypeShortcutDescription": { - "message": "Използвайте един или повече от модификаторите Ctrl, Alt, Win или Shift, заедно с някоя буква." + "editAutotypeKeyboardModifiersDescription": { + "message": "Използвайте един или повече от модификаторите Ctrl, Alt или Win, заедно с някоя буква." }, "invalidShortcut": { "message": "Неправилна комбинация" diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 01a9f1d57d6..607418bcb46 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index a90b00d16e5..9f13b809760 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 41283482c62..1b03ad6fa1e 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 436839c4d8b..437f42f840f 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -4267,7 +4267,7 @@ "typeShortcut": { "message": "Napsat zkratku" }, - "editAutotypeShortcutDescription": { + "editAutotypeKeyboardModifiersDescription": { "message": "Zahrňte jeden nebo dva z následujících modifikátorů: Ctrl, Alt, Win nebo Shift a písmeno." }, "invalidShortcut": { diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index e00bfb52e41..f04f6625529 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 5129d83839c..f022c4cee33 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index b5deafa055e..2783b39ca69 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Autotype-Tastaturkürzel" }, - "editAutotypeShortcutDescription": { - "message": "Füge einen oder zwei der folgenden Modifikatoren ein: Strg, Alt, Win oder Umschalttaste, sowie einen Buchstaben." + "editAutotypeKeyboardModifiersDescription": { + "message": "Füge einen oder zwei der folgenden Modifikatoren ein: Strg, Alt, Win und einen Buchstaben." }, "invalidShortcut": { "message": "Ungültiges Tastaturkürzel" @@ -4307,7 +4307,7 @@ "message": "Wiederherstellen" }, "archived": { - "message": "Archived" + "message": "Archiviert" }, "itemsInArchive": { "message": "Einträge im Archiv" @@ -4331,19 +4331,19 @@ "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Nicht mehr archivieren und speichern" }, "restartPremium": { - "message": "Restart Premium" + "message": "Premium neu starten" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Dein Premium-Abonnement ist abgelaufen" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "Starte dein Premium-Abonnement neu, um den Zugriff auf dein Archiv wiederherzustellen. Wenn du die Details für einen archivierten Eintrag vor dem Neustart bearbeitest, wird er wieder zurück in deinen Tresor verschoben." }, "itemRestored": { - "message": "Item has been restored" + "message": "Eintrag wurde wiederhergestellt" }, "zipPostalCodeLabel": { "message": "PLZ / Postleitzahl" diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index d8329d7d04b..9a4d2b736be 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index d2f191f08c3..f7020c63bf1 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 399087bad95..d4e497f41d3 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index ebbdce12c3d..ab07b9db9af 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 0b456800964..af61f7a97a0 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Atajo inválido" diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index f009785e27e..b013a55ffd7 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 47b3c7ac33f..49b7bad76de 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 977e383fd06..10be871914f 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "تایپ میانبر" }, - "editAutotypeShortcutDescription": { - "message": "شامل یک یا دو مورد از کلیدهای تغییردهنده زیر: Ctrl، Alt، Win یا Shift و یک حرف." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "میانبر نامعتبر" diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 30588bf9ddc..2c2275f3afa 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 0a8b0e89fd9..ff7562680e1 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 577dfad3511..566e6fc7429 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Saisir le raccourci" }, - "editAutotypeShortcutDescription": { - "message": "Inclure un ou deux des modificateurs suivants : Ctrl, Alt, Win, ou Shift, et une lettre." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Raccourci invalide" diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 90eee288681..5a5a1715d7e 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index f723b37d85f..5ce8db992f2 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "הקלד קיצור דרך" }, - "editAutotypeShortcutDescription": { - "message": "כלול אחד או שניים ממקשי הצירוף הבאים: Ctrl, Alt, Win, או Shift, ואות." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "קיצור דרך לא חוקי" diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 2c7e0394c3e..4c376c957d5 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index ce8eaddc1a3..71aa2a5a67a 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Vrsta prečaca" }, - "editAutotypeShortcutDescription": { - "message": "Uključi jedan ili dva modifikatora: Ctrl, Alt, Win ili Shift i slovo." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Nevažeći prečac" diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 374136ee556..fd0667d7aa8 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Tartalmazzon egyet vagy kettőt a következő módosítók közül: Ctrl, Alt, Win és egy betű." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 487a84c11b6..eb84f8bd747 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 175b6b19772..d27efd0789a 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -101,7 +101,7 @@ } }, "new": { - "message": "New", + "message": "Nuovo", "description": "for adding new items" }, "newUri": { @@ -2416,7 +2416,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLink": { - "message": "Copy Send link", + "message": "Copia link del Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -4040,14 +4040,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsTitleNoSearchResults": { - "message": "No search results returned" + "message": "Nessun risultato" }, "sendsBodyNoItems": { "message": "Condividi facilmente file e dati con chiunque, su qualsiasi piattaforma. Le tue informazioni saranno crittografate end-to-end per la massima sicurezza.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoSearchResults": { - "message": "Clear filters or try another search term" + "message": "Elimina i filtri di ricerca o prova con altri termini" }, "generatorNudgeTitle": { "message": "Crea rapidamente password sicure" @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Premi i tasti da impostare per la scorciatoia" }, - "editAutotypeShortcutDescription": { - "message": "Includi uno o due dei seguenti modificatori: Ctrl, Alt, Win, o Shift, più una lettera." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Scorciatoia non valida" @@ -4307,7 +4307,7 @@ "message": "Rimuovi dall'Archivio" }, "archived": { - "message": "Archived" + "message": "Archiviato" }, "itemsInArchive": { "message": "Elementi archiviati" @@ -4331,19 +4331,19 @@ "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e dall'auto-riempimento. Vuoi davvero archiviare questo elemento?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Togli dall'archivio e salva" }, "restartPremium": { - "message": "Restart Premium" + "message": "Riavvia Premium" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Il tuo abbonamento Premium è terminato" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "Per recuperare l'accesso al tuo archivio, riavvia il tuo abbonamento Premium. Se modifichi i dettagli di un elemento archiviato prima del riavvio, sarà spostato nella tua cassaforte." }, "itemRestored": { - "message": "Item has been restored" + "message": "L'elemento è stato ripristinato" }, "zipPostalCodeLabel": { "message": "CAP / codice postale" @@ -4421,7 +4421,7 @@ "message": "Timeout della sessione" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Ridimensiona la navigazione laterale" }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Questa impostazione è gestita dalla tua organizzazione." diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 438828aa1ed..24395bf30d7 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index d1fe2e2b05f..831367b12a8 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 90eee288681..5a5a1715d7e 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index b4509a61d57..d7602b05af2 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 3717ffbdc68..700439e6030 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 71bd35ef34f..e959141e0c9 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 3f4aede37f1..12e548f50b9 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Ievadīt īsinājumtaustiņus" }, - "editAutotypeShortcutDescription": { - "message": "Jāiekļauj viens vai divi no šiem taustiņiem - Ctrl, Alt, Win vai Shift - un burts." + "editAutotypeKeyboardModifiersDescription": { + "message": "Jāiekļauj viens vai divi no šiem taustiņiem: Ctrl, Alt, Win un burts." }, "invalidShortcut": { "message": "Nederīgi īsinājumtaustiņi" diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 312af4a6c1d..fea8b6d97a5 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index debdac6c8c7..f8eb1f682f9 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 90eee288681..5a5a1715d7e 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index db7180a84b9..189044c4c40 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 4338e219145..508e4bbdfbc 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 6d103b6bda6..8d688575099 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 185007135c5..bfbac0c2e65 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Typ de sneltoets" }, - "editAutotypeShortcutDescription": { - "message": "Voeg een of twee van de volgende toetsen toe: Ctrl, Alt, Win of Shift, en een letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Voeg een of twee van de volgende toetsen toe: Ctrl, Alt, Win en een letter." }, "invalidShortcut": { "message": "Ongeldige sneltoets" diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 2a1c43ae342..9119a018cbd 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 9f95f1c0d27..4ffca3c1e6e 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 2f75826a7b5..50a39b462d9 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Rodzaj skrótu" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Skrót jest nieprawidłowy" diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index b0705f9b7c2..a0696b63c1e 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Atalho de digitação" }, - "editAutotypeShortcutDescription": { - "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, e uma letra." }, "invalidShortcut": { "message": "Atalho inválido" diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 09037dd73b5..3b6a818977e 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Introduzir atalho" }, - "editAutotypeShortcutDescription": { - "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win e uma letra." }, "invalidShortcut": { "message": "Atalho inválido" diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 651c6386d7c..b188aa96d1c 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index d367051deaf..6d7b8bf1c23 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Введите сочетание клавиш" }, - "editAutotypeShortcutDescription": { - "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win или Shift и букву." + "editAutotypeKeyboardModifiersDescription": { + "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win и букву." }, "invalidShortcut": { "message": "Недопустимое сочетание клавиш" diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 982d73dd5c6..37f2897c919 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index af256e6d883..b3398629da3 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Zadajte klávesovú skratku" }, - "editAutotypeShortcutDescription": { - "message": "Použite jeden alebo dva z nasledujúcich modifikátorov: Ctrl, Alt, Win, alebo Shift a písmeno." + "editAutotypeKeyboardModifiersDescription": { + "message": "Použite jeden alebo dva z nasledujúcich modifikátorov: Ctrl, Alt, Win a písmeno." }, "invalidShortcut": { "message": "Neplatná klávesová skratka" diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 3cb8df78bbc..6fa6d25285f 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index dd9ce034124..885ed8410db 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Унети пречицу" }, - "editAutotypeShortcutDescription": { - "message": "Укључите један или два следећа модификатора: Ctrl, Alt, Win, или Shift, и слово." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Неважећа пречица" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index ee60c850ce7..f96fff1153c 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Inmatningsgenväg" }, - "editAutotypeShortcutDescription": { - "message": "Inkludera en eller två av följande modifierare: Ctrl, Alt, Win, eller Skift och en bokstav." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inkludera en eller två av följande modifierare: Ctrl, Alt, Win och en bokstav." }, "invalidShortcut": { "message": "Ogiltig genväg" @@ -4343,7 +4343,7 @@ "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." }, "itemRestored": { - "message": "Item has been restored" + "message": "Objektet har återställts" }, "zipPostalCodeLabel": { "message": "Postnummer" diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index f64c237a7c3..d95440ca1a2 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 90eee288681..5a5a1715d7e 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index bc72ae0fc14..b87ed307efc 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 3be00ac1393..59995a02096 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Kısayolu yazın" }, - "editAutotypeShortcutDescription": { - "message": "Aşağıdaki değiştirici tuşlardan birini veya ikisini (Ctrl, Alt, Win ya da Shift) ve bir harf kullanın." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Geçersiz kısayol" diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index ebf6a74ff9b..ed9c76b6ecd 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Введіть комбінацію клавіш" }, - "editAutotypeShortcutDescription": { - "message": "Використайте один або два таких модифікацій: Ctrl, Alt, Win, Shift, і літеру." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Недійсна комбінація клавіш" diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 9ec16480d73..ff711c154bc 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "Phím tắt nhập liệu" }, - "editAutotypeShortcutDescription": { - "message": "Bao gồm một hoặc hai trong số các phím bổ trợ sau: Ctrl, Alt, Win hoặc Shift, và một chữ cái." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Phím tắt không hợp lệ" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index c883192768e..028e7836c41 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "输入快捷键" }, - "editAutotypeShortcutDescription": { - "message": "包含以下一个或两个修饰符:Ctrl、Alt、Win 或 Shift,外加一个字母。" + "editAutotypeKeyboardModifiersDescription": { + "message": "包含以下修饰键中的一个或两个:Ctrl、Alt、Win,以及一个字母。" }, "invalidShortcut": { "message": "无效的快捷键" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 17c7b0867ee..7a3ff01e115 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -4267,8 +4267,8 @@ "typeShortcut": { "message": "輸入快捷鍵" }, - "editAutotypeShortcutDescription": { - "message": "請包含以下修飾鍵之一或兩個:Ctrl、Alt、Win 或 Shift,再加上一個字母。" + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "無效的捷徑" From b4c1d1c149035fc6b3d8d32246e8787ac8d0e4b4 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:22:43 +0100 Subject: [PATCH 067/101] Autosync the updated translations (#18278) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ar/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/az/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/be/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/bg/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/bn/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/bs/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ca/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/cs/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/cy/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/da/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/de/messages.json | 93 +++++++++++++++++++- apps/web/src/locales/el/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/en_GB/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/en_IN/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/eo/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/es/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/et/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/eu/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/fa/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/fi/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/fil/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/fr/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/gl/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/he/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/hi/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/hr/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/hu/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/id/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/it/messages.json | 97 ++++++++++++++++++++- apps/web/src/locales/ja/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ka/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/km/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/kn/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ko/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/lv/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ml/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/mr/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/my/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/nb/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ne/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/nl/messages.json | 91 +++++++++++++++++++- apps/web/src/locales/nn/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/or/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/pl/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/pt_BR/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/pt_PT/messages.json | 97 ++++++++++++++++++++- apps/web/src/locales/ro/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/ru/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/si/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/sk/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/sl/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/sr_CS/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/sr_CY/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/sv/messages.json | 91 +++++++++++++++++++- apps/web/src/locales/ta/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/te/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/th/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/tr/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/uk/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/vi/messages.json | 89 ++++++++++++++++++++ apps/web/src/locales/zh_CN/messages.json | 103 +++++++++++++++++++++-- apps/web/src/locales/zh_TW/messages.json | 89 ++++++++++++++++++++ 63 files changed, 5626 insertions(+), 19 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 850ab193da8..6175da95ec8 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Daar is geen gebeure om te lys nie." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Geaktiveer" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Herroep" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send-skakel", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Onttrek van rekeningterugstel" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index af84960c021..dd114daf78e 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "ليس هناك أعضاء لعرضهم." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "لا توجد أية أحداث لعرضها." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "تم التفعيل" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "استعادة الوصول" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "ملغاة" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "إرسال رابط", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index f14a17a60d0..0cb76ce4cb8 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Sadalanacaq heç bir üzv yoxdur." }, + "noMembersToExport": { + "message": ".dillonvince767@gmail.com" + }, "noEventsInList": { "message": "Sadalanacaq heç bir tədbir yoxdur." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Fəallaşdırıldı" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Erişimi bərpa et" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Ləğv edildi" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "\"Send\" keçidi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Hesab geri qaytarılmasına yazıldınız" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Hesab geri qaytarılması üzrə razılığı geri götür" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Tam onlayn təhlükəsizlik" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index d01e7131107..600eb8cea92 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "У спісе адсутнічаюць удзельнікі." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "У спісе адсутнічаюць падзеі." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Уключана" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Аднавіць доступ" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Адклікана" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Спасылка на Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Зарэгістравацца на аднаўленне ўліковага запісу" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Адклікаць аднаўленне ўліковага запісу" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index dedee053730..dc2dfab4488 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Няма членове за показване." }, + "noMembersToExport": { + "message": "Няма членове за изнасяне." + }, "noEventsInList": { "message": "Няма събития за показване." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Включено" }, + "optionEnabled": { + "message": "Включено" + }, "restoreAccess": { "message": "Възстановяване на достъпа" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Отнет достъп" }, + "accepted": { + "message": "Прието" + }, "sendLink": { "message": "Изпращане на връзката", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Включен във възстановяването на профили" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Оттегляне от възстановяването на профили" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Пълна сигурност в Интернет" + }, + "updatePayment": { + "message": "Актуализиране на плащанията" + }, + "weCouldNotProcessYourPayment": { + "message": "Плащането не беше успешно. Моля, актуализирайте разплащателния си метод или се свържете с екипа по поддръжката за съдействие." + }, + "yourSubscriptionHasExpired": { + "message": "Абонаментът Ви е изтекъл. Моля, свържете се с екипа по поддръжката за съдействие." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Абонаментът Ви по план ще бъде преустановен на $DATE$. Можете да го подновите по всяко време преди това.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Споделяйте още повече със Семейния план, или преминете към подсилената защита на паролите с Екипния план или този за големи организации." + }, + "youHaveAGracePeriod": { + "message": "След като просрочите периода на абонамента си, разполагате с още $DAYS$ дни,. Моля, заплатете старите фактури до $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Управление на фактурите" + }, + "yourNextChargeIsFor": { + "message": "Следващото Ви таксуване ще бъде" + }, + "dueOn": { + "message": "с крайна дата" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Абонаментът Ви ще бъде спрян на" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Абонаментът Ви беше спрян на" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Абонаментът Ви ще бъде прекратен на" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Абонаментът Ви беше прекратен на" + }, + "storageFull": { + "message": "Mястото за съхранение е пълно" + }, + "storageUsedDescription": { + "message": "Използвали сте $USED$ от $AVAILABLE$ GB от наличното си място за съхранение на шифровани данни.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Използвали сте всичките си $GB$ GB от наличното си място за съхранение на шифровани данни. Ако искате да продължите да добавяте файлове, добавете повече място за съхранение." } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 149ac3ebbca..bf8fbc1e603 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "লিঙ্ক পাঠান", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index c162a5464ec..be24796ae06 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Nema događaja za prikaz." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index cb7be2b2bae..9a5f5244f35 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "No hi ha cap membre a llistar." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "No hi ha cap esdeveniment a llistar." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Habilitat" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restaura l'accés" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revocat" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Enllaç Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Inscrit en la recuperació del compte" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Retirar-se de la recuperació del compte" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index a74167ebe0e..5fcfa8d8edc 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Žádní členové k zobrazení." }, + "noMembersToExport": { + "message": "Žádní členové pro export." + }, "noEventsInList": { "message": "Žádné události k zobrazení." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Zapnuto" }, + "optionEnabled": { + "message": "Povoleno" + }, "restoreAccess": { "message": "Obnovit přístup" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Odvoláno" }, + "accepted": { + "message": "Přijato" + }, "sendLink": { "message": "Odkaz pro Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Zapsán do obnovení účtu" }, + "enrolled": { + "message": "Zapsán" + }, + "notEnrolled": { + "message": "Nezapsán" + }, "withdrawAccountRecovery": { "message": "Odstoupit z obnovení účtu" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Dokončit online zabezpečení" + }, + "updatePayment": { + "message": "Aktualizovat platbu" + }, + "weCouldNotProcessYourPayment": { + "message": "Nemohli jsme zpracovat Vaši platbu. Aktualizujte způsob platby nebo kontaktujte tým podpory pro pomoc." + }, + "yourSubscriptionHasExpired": { + "message": "Vaše předplatné vypršelo. Kontaktujte tým podpory pro pomoc." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Zrušení Vašeho předplatného je naplánováno na $DATE$. Před tím ho můžete kdykoli obnovit.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Sdílejte ještě více s rodinami, nebo získejte mocné, důvěryhodné heslo s týmy nebo Enterprise." + }, + "youHaveAGracePeriod": { + "message": "Máte lhůtu k odkladu $DAYS$ dnů od data vypršení předplatného. Vyřešte poslední splatné faktury do $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Spravovat faktury" + }, + "yourNextChargeIsFor": { + "message": "Vaše další platba je za" + }, + "dueOn": { + "message": "splatná" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Vaše předplatné bude pozastaveno" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Vaše předplatné bylo pozastaveno dne" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Vaše předplatné bude zrušeno dne" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Vaše předplatné bylo zrušeno dne" + }, + "storageFull": { + "message": "Úložiště je plné" + }, + "storageUsedDescription": { + "message": "Využili jste $USED$ z $AVAILABLE$ GB Vašeho šifrovaného úložiště.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Využili jste celých $GB$ GB Vašeho šifrovaného úložiště. Chcete-li pokračovat v ukládání souborů, přidejte další úložiště." } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 0aaf4d25956..f8028a95b2d 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 48b77348359..8526782a2e5 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Ingen medlemmer at vise." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Der er ingen begivenheder at vise." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Aktiveret" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Gendan adgang" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Tilbagekaldt" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send-link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Indrulleret i kontogendannelse" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Afmeld fra kontogendannelse" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 8e5de82e539..40f3e64ec1f 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Keine Mitglieder zum Anzeigen vorhanden." }, + "noMembersToExport": { + "message": "Es gibt keine Mitglieder zum Exportieren." + }, "noEventsInList": { "message": "Keine Ereignisse vorhanden." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Aktiviert" }, + "optionEnabled": { + "message": "Aktiviert" + }, "restoreAccess": { "message": "Zugriff wiederherstellen" }, @@ -3147,7 +3153,7 @@ "message": "Starte dein Premium-Abonnement neu, um den Zugriff auf dein Archiv wiederherzustellen. Wenn du die Details für einen archivierten Eintrag vor dem Neustart bearbeitest, wird er wieder zurück in deinen Tresor verschoben." }, "itemRestored": { - "message": "Item has been restored" + "message": "Eintrag wurde wiederhergestellt" }, "restartPremium": { "message": "Premium neu starten" @@ -5649,6 +5655,9 @@ "revoked": { "message": "Widerrufen" }, + "accepted": { + "message": "Akzeptiert" + }, "sendLink": { "message": "Send-Link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Für Kontowiederherstellung registriert" }, + "enrolled": { + "message": "Registriert" + }, + "notEnrolled": { + "message": "Nicht registriert" + }, "withdrawAccountRecovery": { "message": "Von Kontowiederherstellung abmelden" }, @@ -11613,7 +11628,7 @@ "message": "Nicht mehr archivieren" }, "archived": { - "message": "Archived" + "message": "Archiviert" }, "unArchiveAndSave": { "message": "Nicht mehr archivieren und speichern" @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Umfassende Online-Sicherheit" + }, + "updatePayment": { + "message": "Zahlungsmethode aktualisieren" + }, + "weCouldNotProcessYourPayment": { + "message": "Wir konnten deine Zahlung nicht verarbeiten. Bitte aktualisiere deine Zahlungsmethode oder wende dich an das Support-Team, um Hilfe zu erhalten." + }, + "yourSubscriptionHasExpired": { + "message": "Dein Abonnement ist abgelaufen. Bitte kontaktiere das Support-Team für Hilfe." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Dein Abonnement wird am $DATE$ gekündigt. Du kannst es davor jederzeit wieder reaktivieren.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Teile noch mehr mit Families oder erhalte eine leistungsstarke und vertrauenswürdige Passwortsicherheit mit Teams oder Enterprise." + }, + "youHaveAGracePeriod": { + "message": "Du hast eine Nachfrist von $DAYS$ Tagen ab Ablaufdatum deines Abonnements. Bitte begleiche die letzten fälligen Rechnungen bis zum $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Rechnungen verwalten" + }, + "yourNextChargeIsFor": { + "message": "Deine nächste Abbuchung erfolgt am" + }, + "dueOn": { + "message": "fällig am" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Dein Abonnement wird deaktiviert am" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Dein Abonnement wurde deaktiviert am" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Dein Abonnement wird gekündigt am" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Dein Abonnement wurde gekündigt am" + }, + "storageFull": { + "message": "Speicher voll" + }, + "storageUsedDescription": { + "message": "Du hast $USED$ von $AVAILABLE$ GB deines verschlüsselten Datenspeichers verwendet.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Du hast die gesamten $GB$ GB deines verschlüsselten Speichers verwendet. Um mit dem Speichern von Dateien fortzufahren, füge mehr Speicher hinzu." } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index d273e8d8df4..22fef811575 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Δεν υπάρχουν μέλη προς εμφάνιση." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Δεν υπάρχουν γεγονότα στη λίστα." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Ενεργοποιημένο" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Επαναφορά πρόσβασης" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Ανακλήθηκαν" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Αποστολή Συνδέσμου", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Έγινε εγγραφή στην ανάκτηση λογαριασμού" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Απόσυρση από την ανάκτηση λογαριασμού" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 0f2f657e8c4..e940b18682e 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be cancelled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was cancelled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index f222b832edb..a4e904c4e3f 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Enabled" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send Link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be cancelled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was cancelled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 961aa447cce..ecf4ea16a31 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Estas neniu membro por listi." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Estas neniu evento por listi." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Ŝaltita" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Rehavigi aliron" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Sendi ligilon", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 3ffce558575..1ed117a249c 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "No hay miembros para mostrar." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "No hay eventos que listar." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Activado" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Recuperar el acceso" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revocado" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Enlace Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Inscrito en la recuperación de la cuenta" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Retirarse de la recuperación de la cuenta" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 92eed1f84e4..a657243582b 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Puuduvad kasutajad, keda kuvada." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Puuduvad sündmused, mida kuvada." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Sisselülitatud" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Taasta ligipääsu luba" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Eemaldatud" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Sendi link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 3ab60e246c0..c5fbb39790b 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Ez dago erakusteko gertakaririk." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Gaituta" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Sarbidea berreskuratu" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Ezeztatuak" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send esteka", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 40fefff18cf..17c4ba2a1e9 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "هیچ عضوی برای نمایش وجود ندارد." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "هیچ مناسبتی برای نمایش وجود ندارد." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "روشن شده" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "بازیابی دسترسی" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "لغو شد" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "ارسال پیوند", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "در بازیابی حساب کاربری ثبت نام شد" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "برداشت از بازیابی حساب" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index fa5bf0b1f92..6c72705fd8e 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Näytettäviä jäseniä ei ole." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Näytettäviä tapahtumia ei ole." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Käytössä" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Palauta käyttöoikeudet" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Mitätöidyt" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send-linkki", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Liitetty tilin palautusapuun" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Eroa tilin palautusavusta" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index f89d13f6804..c7c0fb18775 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Walang maililistang miyembro." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Walang maililistang kaganapan." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Nakabukas" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Ibalik ang access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Binawi ang" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Ipadala ang link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index c4150f0b1e9..cbf4edfe4b8 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Il n'y a pas de membres à répertorier." }, + "noMembersToExport": { + "message": "Il n'y a aucun membre à exporter." + }, "noEventsInList": { "message": "Aucun événement à afficher." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Activé" }, + "optionEnabled": { + "message": "Activé" + }, "restoreAccess": { "message": "Restaurer l'Accès" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Révoqué" }, + "accepted": { + "message": "Accepté" + }, "sendLink": { "message": "Lien du Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Inscrit à la récupération du compte" }, + "enrolled": { + "message": "Inscrit" + }, + "notEnrolled": { + "message": "Non inscrit" + }, "withdrawAccountRecovery": { "message": "Retirer de la récupération du compte" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Sécurité en ligne complète" + }, + "updatePayment": { + "message": "Mettre à jour le paiement" + }, + "weCouldNotProcessYourPayment": { + "message": "Nous n'avons pas pu traiter votre paiement. Veuillez mettre à jour votre méthode de paiement ou contactez l'équipe de support pour obtenir de l'assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Votre abonnement a expiré. Veuillez contacter l'équipe de suuport pour obtenir de l'assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Votre abonnement est programmé pour terminer le $DATE$. Vous pouvez le rétablir à tout moment avant cette date.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Partagez encore plus avec Familles, ou obtenez une sécurité de mot de passe puissante et fiable avec Équipes ou Entreprise." + }, + "youHaveAGracePeriod": { + "message": "Vous bénéficiez d'une période de grace de $DAYS$ jours suivants la date d'expiration de votre abonnement. Veuillez régler les paiements en souffrance pour les factures dont les échéances sont passées d'ici le $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Gérer les factures" + }, + "yourNextChargeIsFor": { + "message": "Votre prochaine charge est de" + }, + "dueOn": { + "message": "dû le" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Votre abonnement sera suspendu le" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Votre abonnement a été suspendu le" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Votre abonnement sera annulé le" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Votre abonnement a été annulé le" + }, + "storageFull": { + "message": "Stockage plein" + }, + "storageUsedDescription": { + "message": "Vous avez utilisé $USED$ sur $AVAILABLE$ Go de votre stockage de fichiers chiffré.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Vous avez utilisé tous les $GB$ Go de votre stockage chiffré. Pour continuer à stocker des fichiers, ajoutez plus de stockage." } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index b386b769064..facbb21f5a4 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 3774cbe3b3e..5f1969206c2 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "אין חברים להצגה ברשימה." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "אין אירועים להצגה ברשימה." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "מופעל" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "שחזר גישה" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "מבוטל" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "קישור סֵנְד", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "נרשם לשחזור חשבון" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "לסגת משחזור חשבון" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 17fc058b781..eb1fc912225 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index c68b3a6279d..14d469d5d36 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Nema članova za prikaz." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Nema događaja za prikaz." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Omogućeno" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Vrati pristup" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Opozvano" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Veza na Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Oporavak računa uključen" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Isključi oporavak računa" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 3ff717d534c..76cf76f205c 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Nincsenek megjeleníthető tagok." }, + "noMembersToExport": { + "message": "Nincsenek exportálható tagok." + }, "noEventsInList": { "message": "Nincsenek megjeleníthető események." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Engedélyezve" }, + "optionEnabled": { + "message": "Engedélyezve" + }, "restoreAccess": { "message": "Hozzáférés helyreállítás" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "A visszavonás megtörtént." }, + "accepted": { + "message": "Elfogadva" + }, "sendLink": { "message": "Send hivatkozás", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Megtörtént a regisztráció fiók helyreállításra." }, + "enrolled": { + "message": "Feliratkozott" + }, + "notEnrolled": { + "message": "Nem feliratkozott" + }, "withdrawAccountRecovery": { "message": "Kilépés a fiók helyreállításból" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Teljes körű online biztonság" + }, + "updatePayment": { + "message": "Fizetés frissítése" + }, + "weCouldNotProcessYourPayment": { + "message": "Nem lehetett feldolgozni a fizetést. Frissítsük a fizetési módot vagy forduljunk segítségért az ügyfélszolgálathoz." + }, + "yourSubscriptionHasExpired": { + "message": "Az előfizetés lejárt. Forduljunk a támogató csapathoz segítségért." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Az előfizetés az időzítés szerint lejár: $DATE$. Ezt megelőzően bármikor visszaállíthatjuk.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Osszunk meg még többet a Családi csomaggal vagy kapjunk hatékony, megbízható jelszóbiztonságot a Teams vagy az Enterprise segítségével." + }, + "youHaveAGracePeriod": { + "message": "Az előfizetés lejárati dátumától számítva $DAYS$ nap türelmi időszak áll rendelkezésére az előfizetés fenntartásához. Rendezzük a lejárt számlákat a következő időpontig: $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Számlák kezelése" + }, + "yourNextChargeIsFor": { + "message": "Következő terhelés" + }, + "dueOn": { + "message": "esedékes" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Az előfizetés felfüggesztésre kerül:" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Az előfizetés felfüggesztésre került:" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Az előfizetés törlésre kerül:" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Az előfizetés törlésre került:" + }, + "storageFull": { + "message": "A tárhely megtelt." + }, + "storageUsedDescription": { + "message": "$USED$ / $AVAILABLE$ GB lett felhasználva a titkosított fájl tárolóból.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "A titkosított tárhely összes $GB$ mérete felhasználásra került. A fájlok tárolásának folytatásához adjunk hozzá további tárhelyet." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 73a1d220918..95d393d82b0 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Tidak ada anggota untuk ditampilkan." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Tidak ada acara untuk dicantumkan." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Diaktifkan" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Pulihkan Akses" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Dicabut" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Kirim Tautan", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index b9a2b98dc18..63a659662f8 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Nessun membro da mostrare." }, + "noMembersToExport": { + "message": "Nessun elemento da esportare." + }, "noEventsInList": { "message": "Nessun evento da mostrare." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Attivato" }, + "optionEnabled": { + "message": "Attivo" + }, "restoreAccess": { "message": "Ripristina accesso" }, @@ -2635,7 +2641,7 @@ "message": "Chiave" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Chiave senza nome" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Codice di verifica" @@ -3147,7 +3153,7 @@ "message": "Per recuperare l'accesso al tuo archivio, riavvia il tuo abbonamento Premium. Se modifichi i dettagli di un elemento archiviato prima del riavvio, sarà spostato nella tua cassaforte." }, "itemRestored": { - "message": "Item has been restored" + "message": "L'elemento è stato ripristinato" }, "restartPremium": { "message": "Riavvia Premium" @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revocato" }, + "accepted": { + "message": "Approvato" + }, "sendLink": { "message": "Link del Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Iscritto al recupero dell'account" }, + "enrolled": { + "message": "Registrato" + }, + "notEnrolled": { + "message": "Non registrato" + }, "withdrawAccountRecovery": { "message": "Rifiuta il recupero dell'account" }, @@ -11613,7 +11628,7 @@ "message": "Togli dall'archivio" }, "archived": { - "message": "Archived" + "message": "Archiviato" }, "unArchiveAndSave": { "message": "Togli dall'archivio e salva" @@ -12308,7 +12323,7 @@ "message": "Verifica dell'utente non riuscita." }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Ridimensiona la navigazione laterale" }, "recoveryDeleteCiphersTitle": { "message": "Elimina gli oggetti della cassaforte non recuperabili" @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Sicurezza online completa" + }, + "updatePayment": { + "message": "Aggiorna il metodo di pagamento" + }, + "weCouldNotProcessYourPayment": { + "message": "Non è stato possibile elaborare la transazione. Aggiorna il metodo di pagamento o contatta l'assistenza." + }, + "yourSubscriptionHasExpired": { + "message": "Il tuo abbonamento è scaduto. Contatta il team di supporto per ricevere assistenza." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Il tuo abbonamento è programmato per terminare il $DATE$. Puoi ripristinarlo in qualsiasi momento prima di quella data.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Condividi ancora di più con il piano famiglie, o rafforza le protezioni con Team o Enterprise." + }, + "youHaveAGracePeriod": { + "message": "Hai un periodo di grazia di $DAYS$ giorni dalla fine dell'abbonamento. Ti preghiamo di provvedere ai pagamenti entro il $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Gestisci le fatture" + }, + "yourNextChargeIsFor": { + "message": "Il tuo prossimo addebito è programmato" + }, + "dueOn": { + "message": "per" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Il tuo abbonamento sarà sospeso il" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Il tuo abbonamento è stato sospeso il" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Il tuo abbonamento terminerà il" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Il tuo abbonamento è terminato il" + }, + "storageFull": { + "message": "Spazio di archiviazione pieno" + }, + "storageUsedDescription": { + "message": "Hai usato $USED$ GB su $AVAILABLE$ del tuo archivio file crittografato.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Hai usato tutti i $GB$ GB del tuo spazio di archiviazione crittografato. Per archiviare altri file, aggiungi altro spazio." } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 2e290f0e4b9..79e65adbd7b 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "表示できるメンバーがいません。" }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "表示するイベントがありません" }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "有効化されました" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "アクセスを復元する" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "取り消し済み" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send リンク", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "アカウント回復に登録しました" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "アカウント回復から登録解除する" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 4c6c8a6572c..a3806147491 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "არაა წევრები ჩამოსათველად." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "არაა მოვლენები ჩამოსათველად." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 09f111c30c5..8024de21e56 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index b0e03d029cf..c0fe31906ee 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "ಪಟ್ಟಿ ಮಾಡಲು ಯಾವುದೇ ಘಟನೆಗಳಿಲ್ಲ." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "ಸಕ್ರಿಯಗೊಳಿಸಿದೆ" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "ಲಿಂಕ್ ಕಳುಹಿಸಿ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index a8eb53841ec..3f56c424333 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "이벤트가 없습니다." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "활성화됨" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send 링크", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index dc6ddbce784..f1ba805ae78 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Nav dalībnieku, ko uzskaitīt." }, + "noMembersToExport": { + "message": "Nav dalībnieku, ko izgūt." + }, "noEventsInList": { "message": "Nav notikumu, ko parādīt." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Iespējots" }, + "optionEnabled": { + "message": "Iespējots" + }, "restoreAccess": { "message": "Atjaunot piekļuvi" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Atsauktie" }, + "accepted": { + "message": "Pieņemts" + }, "sendLink": { "message": "Send saite", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Pieteicies konta atkopei" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Atsaukt konta atkopi" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Pilnīga drošība tiešsaistē" + }, + "updatePayment": { + "message": "Atjaunināt maksājumu" + }, + "weCouldNotProcessYourPayment": { + "message": "Mēs nevarējām apstrādāt maksājumu. Lūgums atjaunināt savu maksājumu veidu vai sazināties ar atbalsta komandu, lai iegūtu palīdzību." + }, + "yourSubscriptionHasExpired": { + "message": "Abonements ir beidzies. Lūgums sazināties ar atbalsta komandu, lai iegūtu palīdzību." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Abonements ir ierindots atcelšanai $DATE$. Līdz tam jebkurā brīdī to var atjaunot.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index dcf3e950dfe..a58e8e310ad 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "പ്രദർശിപ്പിക്കാൻ ഇവന്റുകളൊന്നുമില്ല." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "പ്രവർത്തനക്ഷമമാക്കി" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "ലിങ്ക് അയയ്‌ക്കുക", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 70771c9fbed..eb6d5b917c7 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 09f111c30c5..8024de21e56 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 8bf79f74f07..04f22415ac3 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Det er ingen medlemmer å vise." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Det er ingen hendelser å liste opp." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Aktivert" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Gjenopprett tilgang" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Opphevet" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send lenke", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 75f4b5524c3..70b3478386f 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index f2f2cebb236..ea802c9aca6 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -1747,7 +1747,10 @@ "message": "Er zijn geen gebruikers om weer te geven." }, "noMembersInList": { - "message": "Er zijn geen leden op weer te geven." + "message": "Er zijn geen leden om weer te geven." + }, + "noMembersToExport": { + "message": "Er zijn geen leden om te exporteren." }, "noEventsInList": { "message": "Er zijn geen gebeurtenissen om weer te geven." @@ -2537,6 +2540,9 @@ "enabled": { "message": "Ingeschakeld" }, + "optionEnabled": { + "message": "Ingeschakeld" + }, "restoreAccess": { "message": "Toegang herstellen" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Ingetrokken" }, + "accepted": { + "message": "Geaccepteerd" + }, "sendLink": { "message": "Send-koppeling", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Aangemeld bij accountherstel" }, + "enrolled": { + "message": "Ingeschreven" + }, + "notEnrolled": { + "message": "Niet ingeschreven" + }, "withdrawAccountRecovery": { "message": "Afmelden voor accountherstel" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Online beveiliging voltooien" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 1e4f376f083..44d7dbf39c1 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Det er ingen hendingar å syna." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 09f111c30c5..8024de21e56 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 272b53eac23..7f2e8da75bd 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Brak członków do wyświetlenia." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Brak wydarzeń do wyświetlenia." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Włączone" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Przywróć dostęp" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Unieważnieni" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Link wysyłki", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Dołączono do odzyskiwania konta" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Wycofaj z odzyskiwania konta" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 5f5d4500bef..3faa9ef7729 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Não há membros para listar." }, + "noMembersToExport": { + "message": "Não há membros para exportar." + }, "noEventsInList": { "message": "Não há eventos para listar." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Ativado" }, + "optionEnabled": { + "message": "Ativado" + }, "restoreAccess": { "message": "Restaurar acesso" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revogados" }, + "accepted": { + "message": "Aceito" + }, "sendLink": { "message": "Link do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Inscrito na recuperação de conta" }, + "enrolled": { + "message": "Inscrito" + }, + "notEnrolled": { + "message": "Não inscrito" + }, "withdrawAccountRecovery": { "message": "Retirar-se da recuperação de conta" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Segurança on-line completa" + }, + "updatePayment": { + "message": "Atualizar pagamento" + }, + "weCouldNotProcessYourPayment": { + "message": "Não pudemos processar seu pagamento. Atualize seu método de pagamento ou entre em contato com a equipe de suporte para assistência." + }, + "yourSubscriptionHasExpired": { + "message": "Sua assinatura expirou. Entre em contato com a equipe de suporte para obter assistência." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Sua assinatura está agendada para ser cancelada em $DATE$. Você pode reestabelecê-la a qualquer momento antes disso.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Compartilhe ainda mais com o Famílias, ou receba segurança poderosa e confiável de senhas com o Equipes ou o Empresarial." + }, + "youHaveAGracePeriod": { + "message": "Você tem um período de tolerância de $DAYS$ dias até a data de expiração da sua assinatura. Resolva as faturas atrasadas até $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Gerenciar faturas" + }, + "yourNextChargeIsFor": { + "message": "Sua próxima cobrança é de" + }, + "dueOn": { + "message": "para" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Sua assinatura será suspensa em" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Sua assinatura foi suspensa em" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Sua assinatura será cancelada em" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Sua assinatura foi cancelada em" + }, + "storageFull": { + "message": "Armazenamento cheio" + }, + "storageUsedDescription": { + "message": "Você usou $USED$ dos $AVAILABLE$ GB do seu armazenamento de arquivos criptografados.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Você usou todos os $GB$ GB do seu armazenamento criptografado. Para continuar armazenando arquivos, adicione mais armazenamento." } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index ae6d11eab21..1915d4b49f3 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Não existem membros para listar." }, + "noMembersToExport": { + "message": "Não existem membros para exportar." + }, "noEventsInList": { "message": "Não existem eventos para listar." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Ativado" }, + "optionEnabled": { + "message": "Ativado" + }, "restoreAccess": { "message": "Restaurar o acesso" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revogado" }, + "accepted": { + "message": "Aceite" + }, "sendLink": { "message": "Link do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Inscrito na recuperação de conta" }, + "enrolled": { + "message": "Inscrito" + }, + "notEnrolled": { + "message": "Não inscrito" + }, "withdrawAccountRecovery": { "message": "Retirar-se da recuperação de conta" }, @@ -8839,7 +8854,7 @@ "message": "Ajuda na licença de funcionalidades pagas" }, "selfHostGracePeriodHelp": { - "message": "Após a expiração da sua subscrição, tem 60 dias para aplicar um ficheiro de licença atualizado à sua organização. O período de carência termina a $GRACE_PERIOD_END_DATE$.", + "message": "Após a expiração da sua subscrição, dispõe de 60 dias para aplicar um ficheiro de licença atualizado à sua organização. O período de tolerância termina a $GRACE_PERIOD_END_DATE$.", "placeholders": { "GRACE_PERIOD_END_DATE": { "content": "$1", @@ -10040,7 +10055,7 @@ "description": "The date header used when a subscription is past due." }, "pastDueWarningForChargeAutomatically": { - "message": "Dispõe de um período de carência de $DAYS$ dias a partir da data de expiração da sua subscrição para manter a sua subscrição. Por favor, resolva as faturas vencidas até $SUSPENSION_DATE$.", + "message": "Dispõe de um período de tolerância de $DAYS$ dias a partir da data de expiração da sua subscrição para manter a subscrição ativa. Por favor, resolva as faturas em atraso até $SUSPENSION_DATE$.", "placeholders": { "days": { "content": "$1", @@ -10054,7 +10069,7 @@ "description": "A warning shown to the user when their subscription is past due and they are charged automatically." }, "pastDueWarningForSendInvoice": { - "message": "Dispõe de um período de carência de $DAYS$ dias a partir da data de vencimento da sua primeira fatura não paga para manter a sua subscrição. Por favor, resolva as faturas vencidas até $SUSPENSION_DATE$.", + "message": "Dispõe de um período de tolerância de $DAYS$ dias a partir da data de vencimento da sua primeira fatura em atraso para manter a subscrição ativa. Por favor, resolva as faturas em atraso até $SUSPENSION_DATE$.", "placeholders": { "days": { "content": "$1", @@ -11468,7 +11483,7 @@ } }, "resellerPastDueWarningMsg": { - "message": "A fatura da sua subscrição não foi paga. Para garantir um serviço ininterrupto, contacte a $RESELLER$ para confirmar a sua renovação antes de $GRACE_PERIOD_END$.", + "message": "A fatura da sua subscrição não foi paga. Para garantir a continuidade do serviço, contacte a $RESELLER$ para confirmar a sua renovação antes de $GRACE_PERIOD_END$.", "placeholders": { "reseller": { "content": "$1", @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Segurança total online" + }, + "updatePayment": { + "message": "Atualizar pagamento" + }, + "weCouldNotProcessYourPayment": { + "message": "Não foi possível processar o seu pagamento. Por favor, atualize o seu método de pagamento ou contacte a equipa de suporte para obter assistência." + }, + "yourSubscriptionHasExpired": { + "message": "A sua subscrição expirou. Por favor, contacte a equipa de suporte para obter assistência." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "A sua subscrição está agendada para ser cancelada em $DATE$. Pode reativá-la a qualquer momento até essa data.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Partilhe ainda mais com o plano Familiar ou obtenha uma segurança de palavras-passe poderosa e fiável com os planos Equipas ou Empresarial." + }, + "youHaveAGracePeriod": { + "message": "Dispõe de um período de tolerância de $DAYS$ dias a partir da data de expiração da sua subscrição. Por favor, resolva as faturas em atraso até $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Gerir faturas" + }, + "yourNextChargeIsFor": { + "message": "O seu próximo pagamento é de" + }, + "dueOn": { + "message": "com vencimento a" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "A sua subscrição será suspensa a" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "A sua subscrição foi suspensa a" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "A sua subscrição será cancelada a" + }, + "yourSubscriptionWasCanceledOn": { + "message": "A sua subscrição foi cancelada a" + }, + "storageFull": { + "message": "Armazenamento cheio" + }, + "storageUsedDescription": { + "message": "Utilizou $USED$ de $AVAILABLE$ GB do seu armazenamento de ficheiros encriptados.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Utilizou os $GB$ GB do seu armazenamento encriptado. Para continuar a guardar ficheiros, adicione mais espaço de armazenamento." } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 490b33dd8ae..b8be7a0f162 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Niciun eveniment de afișat." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Activat" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restaurare acces" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revocat" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Link Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 5fe8d27139a..91f6ecf9946 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Нет участников для отображения." }, + "noMembersToExport": { + "message": "Нет участников для экспорта." + }, "noEventsInList": { "message": "Нет событий для отображения." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Включено" }, + "optionEnabled": { + "message": "Включено" + }, "restoreAccess": { "message": "Восстановить доступ" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Отозвано" }, + "accepted": { + "message": "Принято" + }, "sendLink": { "message": "Ссылка на Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Зарегистрирован на восстановление аккаунта" }, + "enrolled": { + "message": "Зарегистрировано" + }, + "notEnrolled": { + "message": "Не зарегистрировано" + }, "withdrawAccountRecovery": { "message": "Сняться с восстановления аккаунта" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Полная онлайн-защищенность" + }, + "updatePayment": { + "message": "Обновить платежную информацию" + }, + "weCouldNotProcessYourPayment": { + "message": "Нам не удалось обработать ваш платеж. Пожалуйста, обновите свой способ оплаты или обратитесь за помощью в службу поддержки." + }, + "yourSubscriptionHasExpired": { + "message": "Срок действия вашей подписки истек. Пожалуйста, обратитесь за помощью в службу поддержки." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Ваша подписка будет отменена $DATE$. Вы сможете восстановить ее в любое время до этого момента.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Делитесь еще большим количеством информации с семьями или обеспечьте надежную защиту паролем с командами или организациями." + }, + "youHaveAGracePeriod": { + "message": "У вас есть льготный период $DAYS$ дней с даты истечения срока действия вашей подписки. Пожалуйста, оплатите просроченные счета к $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Управление счетами" + }, + "yourNextChargeIsFor": { + "message": "Ваша следующая оплата будет за" + }, + "dueOn": { + "message": "срок" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Ваша подписка будет приостановлена в" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Ваша подписка была приостановлена" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Ваша подписка будет отменена в" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Ваша подписка была отменена" + }, + "storageFull": { + "message": "Хранилище заполнено" + }, + "storageUsedDescription": { + "message": "Вы использовали $USED$ из $AVAILABLE$ ГБ вашего зашифрованного файлового хранилища.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "Вы использовали все $GB$ вашего зашифрованного хранилища. Чтобы продолжить хранение файлов, добавьте дополнительное хранилище." } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 34f5fdb6a56..4f0dc60ad6d 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index b881394a479..159f90dec1d 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Neexistujú žiadni členovia na zobrazenie." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Neexistujú žiadne udalosti na zobrazenie." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Povolené" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Obnoviť prístup" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Zrušený prístup" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Odkaz na Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Zapísaný na obnovu konta" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Odhlásiť sa z obnovy konta" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index edd02eaf75d..62edf2df74a 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Ni članov za prikaz." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Ni dogodkov za prikaz." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Omogočeno" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Obnovi dostop" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Povezava pošiljke", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index cd0749da2ad..cd9c52f3f02 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Omogućeno" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index 526557d97e3..d1a4b3776ef 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Нема чланова за приказивање." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Нема догађаја у листи." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Омогућено" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Врати притуп" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Опозвано" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "УРЛ „Send“", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Уписан/а у опоравак налога" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Повуците са опоравка налога" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index b39eeaf1fac..909a17ef7c4 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Det finns inga medlemmar att visa." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Det finns inga händelser att visa." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Aktiverad" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Återställ åtkomst" }, @@ -3147,7 +3153,7 @@ "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-prenumerationen. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." }, "itemRestored": { - "message": "Item has been restored" + "message": "Objektet har återställts" }, "restartPremium": { "message": "Starta om Premium" @@ -5649,6 +5655,9 @@ "revoked": { "message": "Återkallad" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send-länk", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Registrerad i kontoåterställning" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Uttag från kontoåterställning" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Komplett säkerhet online" + }, + "updatePayment": { + "message": "Uppdatera betalning" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Ditt abonnemang har löpt ut. Kontakta supportteamet för hjälp." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Ditt abonnemang är planerat att avslutas den $DATE$. Du kan återställa det när som helst innan dess.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Hantera fakturor" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Lagringen är full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 348147d044c..7b6eb08c614 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "பட்டியலிட உறுப்பினர்கள் யாரும் இல்லை." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "பட்டியலிட நிகழ்வுகள் எதுவும் இல்லை." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "இயக்கப்பட்டது" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "அணுகலை மீட்டமை" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "திரும்பப் பெறப்பட்டது" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send இணைப்பு", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "கணக்கு மீட்டெடுப்பில் பதிவுசெய்யப்பட்டது" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "கணக்கு மீட்டெடுப்பிலிருந்து விலகு" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 09f111c30c5..8024de21e56 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 355dfda0f49..ea103206532 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "ไม่มีเหตุการณ์สำหรับแสดง" }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index fe000f73ed7..97181dff010 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Listelenecek üye yok." }, + "noMembersToExport": { + "message": "Dışa aktarılacak üye yok." + }, "noEventsInList": { "message": "Listelenecek olay yok." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Etkinleştirildi" }, + "optionEnabled": { + "message": "Etkinleştirildi" + }, "restoreAccess": { "message": "Erişimi geri getir" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "İptal edildi" }, + "accepted": { + "message": "Kabul edildi" + }, "sendLink": { "message": "Send bağlantısı", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Hesap kurtarmaya kaydolundu" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Hesap kurtarmadan ayrıl" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Eksiksiz çevrimiçi güvenlik" + }, + "updatePayment": { + "message": "Ödemeyi güncelle" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Faturaları yönet" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Depolama alanı dolu" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index e9d1a3e1551..155b0eed971 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Список не містить учасників." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Немає подій." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Увімкнено" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Відновити доступ" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Відкликані" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Посилання на відправлення", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Розгорнуто на відновлення облікового запису" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Відкликати відновлення облікового запису" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 3e1f74bccba..803c283fa52 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "Không có người nào để liệt kê." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "Chưa có sự kiện nào." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Kích hoạt" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Khôi phục quyền truy cập" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "Đã thu hồi" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Liên kết Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Đã đăng ký khôi phục tài khoản" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Rút khỏi khôi phục tài khoản" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index f6b2adfc3da..a5cca606972 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -236,7 +236,7 @@ "message": "标记为关键的应用程序" }, "criticalApplicationsMarkedSuccess": { - "message": "$COUNT$ 个标记为关键的应用程序", + "message": "$COUNT$ 个应用程序标记为关键", "placeholders": { "count": { "content": "$1", @@ -341,7 +341,7 @@ "message": "总的应用程序" }, "applicationsNeedingReview": { - "message": "应用程序需要审查" + "message": "需要审查的应用程序" }, "newApplicationsCardTitle": { "message": "审查新应用程序" @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "没有可列出的成员。" }, + "noMembersToExport": { + "message": "没有可导出的成员。" + }, "noEventsInList": { "message": "没有可列出的事件。" }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "已启用" }, + "optionEnabled": { + "message": "已启用" + }, "restoreAccess": { "message": "恢复访问权限" }, @@ -3219,7 +3225,7 @@ "message": "您的方案包含了 7 天的免费试用。在试用期结束前,不会从您的付款方式中扣款。您可以随时取消。" }, "paymentInformation": { - "message": "支付信息" + "message": "付款信息" }, "billingInformation": { "message": "计费信息" @@ -3228,7 +3234,7 @@ "message": "在 7 天免费试用期间,不会从您的付款方式中扣款。" }, "creditCard": { - "message": "支付卡" + "message": "信用卡" }, "paypalClickSubmit": { "message": "选择 PayPal 按钮登录您的 PayPal 账户,然后点击下面的「提交」按钮继续。" @@ -5235,7 +5241,7 @@ "message": "您的 API 密钥可用于验证 Bitwarden 公共 API。" }, "apiKeyRotateDesc": { - "message": "轮换 API 密钥将使前一个密钥失效。如果您认为当前密钥不再安全,可以轮换 API 密钥。" + "message": "轮换 API 密钥将使之前的密钥失效。如果您认为当前密钥不再安全,您可以轮换您的 API 密钥。" }, "apiKeyWarning": { "message": "您的 API 密钥拥有组织的全部访问权限。请严格保密。" @@ -5649,6 +5655,9 @@ "revoked": { "message": "已撤销" }, + "accepted": { + "message": "已接受" + }, "sendLink": { "message": "Send 链接", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5941,7 +5950,7 @@ "message": "自动用户确认可能对您的组织数据带来安全风险。" }, "autoConfirmAcceptSecurityRiskLearnMore": { - "message": "进一步了解此风险", + "message": "了解此风险", "description": "The is the link copy for the first check box option in the edit policy dialog" }, "autoConfirmSingleOrgRequired": { @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "已注册账户恢复" }, + "enrolled": { + "message": "已注册" + }, + "notEnrolled": { + "message": "未注册" + }, "withdrawAccountRecovery": { "message": "撤销账户恢复" }, @@ -10852,7 +10867,7 @@ "message": "进一步了解搜索密码库" }, "learnMoreAboutYourAccountFingerprintPhrase": { - "message": "进一步了解账户指纹短语" + "message": "了解您的账户指纹短语" }, "impactOfRotatingYourEncryptionKey": { "message": "轮换加密密钥的影响" @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "全面的在线安全防护" + }, + "updatePayment": { + "message": "更新付款信息" + }, + "weCouldNotProcessYourPayment": { + "message": "我们无法处理您的付款。请更新您的付款方式或联系支持团队寻求帮助。" + }, + "yourSubscriptionHasExpired": { + "message": "您的订阅已过期。请联系支持团队寻求帮助。" + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "您的订阅将于 $DATE$ 取消。在此日期之前,您可以随时恢复订阅。", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "使用家庭版共享更多内容,或使用团队版或企业版获得强大、可信赖的密码安全防护。" + }, + "youHaveAGracePeriod": { + "message": "从您的订阅到期之日起,您有 $DAYS$ 天的宽限期。请在 $DATE$ 之前处理逾期未支付的账单。", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "管理账单" + }, + "yourNextChargeIsFor": { + "message": "您的下一次收费是用于" + }, + "dueOn": { + "message": "到期日期为" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "您的订阅将被暂停于" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "您的订阅被暂停于" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "您的订阅将被取消于" + }, + "yourSubscriptionWasCanceledOn": { + "message": "您的订阅被取消于" + }, + "storageFull": { + "message": "存储空间已满" + }, + "storageUsedDescription": { + "message": "总计 $AVAILABLE$ GB 加密文件存储空间,您已使用 $USED$ GB。", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "您已使用了全部的 $GB$ GB 加密存储空间。要继续存储文件,请添加更多存储空间。" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 9b44b0c3b27..5fc80815483 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "沒有可列出的成員。" }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "沒有可列出的事件。" }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "已啟用" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "還原存取權限" }, @@ -5649,6 +5655,9 @@ "revoked": { "message": "已撤銷" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send 連結", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -6307,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "已注冊帳戶復原" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "撤銷帳戶復原" }, @@ -12491,5 +12506,79 @@ }, "planDescPremium": { "message": "完整的線上安全防護" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } From f7f4ac0bcc6fc4cd9c891db14300596040718440 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:22:56 +0100 Subject: [PATCH 068/101] Autosync the updated translations (#18277) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 18 +++++++++++++ apps/browser/src/_locales/az/messages.json | 18 +++++++++++++ apps/browser/src/_locales/be/messages.json | 18 +++++++++++++ apps/browser/src/_locales/bg/messages.json | 18 +++++++++++++ apps/browser/src/_locales/bn/messages.json | 18 +++++++++++++ apps/browser/src/_locales/bs/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ca/messages.json | 18 +++++++++++++ apps/browser/src/_locales/cs/messages.json | 18 +++++++++++++ apps/browser/src/_locales/cy/messages.json | 18 +++++++++++++ apps/browser/src/_locales/da/messages.json | 18 +++++++++++++ apps/browser/src/_locales/de/messages.json | 24 ++++++++++++++--- apps/browser/src/_locales/el/messages.json | 18 +++++++++++++ apps/browser/src/_locales/en_GB/messages.json | 18 +++++++++++++ apps/browser/src/_locales/en_IN/messages.json | 18 +++++++++++++ apps/browser/src/_locales/es/messages.json | 18 +++++++++++++ apps/browser/src/_locales/et/messages.json | 18 +++++++++++++ apps/browser/src/_locales/eu/messages.json | 18 +++++++++++++ apps/browser/src/_locales/fa/messages.json | 18 +++++++++++++ apps/browser/src/_locales/fi/messages.json | 18 +++++++++++++ apps/browser/src/_locales/fil/messages.json | 18 +++++++++++++ apps/browser/src/_locales/fr/messages.json | 18 +++++++++++++ apps/browser/src/_locales/gl/messages.json | 18 +++++++++++++ apps/browser/src/_locales/he/messages.json | 18 +++++++++++++ apps/browser/src/_locales/hi/messages.json | 18 +++++++++++++ apps/browser/src/_locales/hr/messages.json | 18 +++++++++++++ apps/browser/src/_locales/hu/messages.json | 18 +++++++++++++ apps/browser/src/_locales/id/messages.json | 18 +++++++++++++ apps/browser/src/_locales/it/messages.json | 26 ++++++++++++++++--- apps/browser/src/_locales/ja/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ka/messages.json | 18 +++++++++++++ apps/browser/src/_locales/km/messages.json | 18 +++++++++++++ apps/browser/src/_locales/kn/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ko/messages.json | 18 +++++++++++++ apps/browser/src/_locales/lt/messages.json | 18 +++++++++++++ apps/browser/src/_locales/lv/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ml/messages.json | 18 +++++++++++++ apps/browser/src/_locales/mr/messages.json | 18 +++++++++++++ apps/browser/src/_locales/my/messages.json | 18 +++++++++++++ apps/browser/src/_locales/nb/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ne/messages.json | 18 +++++++++++++ apps/browser/src/_locales/nl/messages.json | 18 +++++++++++++ apps/browser/src/_locales/nn/messages.json | 18 +++++++++++++ apps/browser/src/_locales/or/messages.json | 18 +++++++++++++ apps/browser/src/_locales/pl/messages.json | 22 ++++++++++++++-- apps/browser/src/_locales/pt_BR/messages.json | 18 +++++++++++++ apps/browser/src/_locales/pt_PT/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ro/messages.json | 18 +++++++++++++ apps/browser/src/_locales/ru/messages.json | 18 +++++++++++++ apps/browser/src/_locales/si/messages.json | 18 +++++++++++++ apps/browser/src/_locales/sk/messages.json | 18 +++++++++++++ apps/browser/src/_locales/sl/messages.json | 18 +++++++++++++ apps/browser/src/_locales/sr/messages.json | 18 +++++++++++++ apps/browser/src/_locales/sv/messages.json | 20 +++++++++++++- apps/browser/src/_locales/ta/messages.json | 18 +++++++++++++ apps/browser/src/_locales/te/messages.json | 18 +++++++++++++ apps/browser/src/_locales/th/messages.json | 18 +++++++++++++ apps/browser/src/_locales/tr/messages.json | 18 +++++++++++++ apps/browser/src/_locales/uk/messages.json | 18 +++++++++++++ apps/browser/src/_locales/vi/messages.json | 18 +++++++++++++ apps/browser/src/_locales/zh_CN/messages.json | 18 +++++++++++++ apps/browser/src/_locales/zh_TW/messages.json | 18 +++++++++++++ 61 files changed, 1108 insertions(+), 10 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index debe3f82c1b..bfea785fc63 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 547ebee9500..f94b0bfc501 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Konsolu" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Hesab güvənliyi" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 5f765c0045d..81f7fefdd9f 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Кансоль адміністратара" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Бяспеке акаўнта" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index c94e8ff2fdc..4fcca092639 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Административна конзола" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматично потвърждение на потребителите" + }, + "automaticUserConfirmationHint": { + "message": "Автоматично потвърждение на потребителите, когато това устройство е отключено" + }, + "autoConfirmOnboardingCallout": { + "message": "Спестете време с автоматичното потвърждение на потребителите" + }, + "autoConfirmWarning": { + "message": "Това може да се отрази на сигурността на данните в организацията Ви. " + }, + "autoConfirmWarningLink": { + "message": "Научете повече за рисковете" + }, "accountSecurity": { "message": "Защита на регистрацията" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 106b61dd9f8..753418ab603 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index ac17bac7097..a9a6c77e7ae 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 96944f45b5b..c195fd8c0b2 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Consola d'administració" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Seguretat del compte" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 1d00f81a62c..1087cf67c7c 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Konzole správce" }, + "admin": { + "message": "Administrátor" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrzení uživatele" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdit čekající uživatele, když je toto zařízení odemčeno" + }, + "autoConfirmOnboardingCallout": { + "message": "Ušetřete čas s automatickým potvrzením uživatele" + }, + "autoConfirmWarning": { + "message": "To by mohlo ovlivnit bezpečnost dat Vaší organizace. " + }, + "autoConfirmWarningLink": { + "message": "Více o rizicích" + }, "accountSecurity": { "message": "Zabezpečení účtu" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index c6a380da1a6..0b8d45e4042 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Diogelwch eich cyfrif" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 4871e7e7100..8b35b2049ca 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin-konsol" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Kontosikkerhed" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index d996c1d0835..8363d97c5e2 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -586,7 +586,7 @@ "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, "itemRestored": { - "message": "Item has been restored" + "message": "Eintrag wurde wiederhergestellt" }, "edit": { "message": "Bearbeiten" @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Administrator-Konsole" }, + "admin": { + "message": "Administrator" + }, + "automaticUserConfirmation": { + "message": "Automatische Benutzerbestätigung" + }, + "automaticUserConfirmationHint": { + "message": "Ausstehende Benutzer automatisch bestätigen, während dieses Gerät entsperrt ist" + }, + "autoConfirmOnboardingCallout": { + "message": "Spare Zeit durch die automatische Benutzerbestätigung" + }, + "autoConfirmWarning": { + "message": "Dies könnte die Datensicherheit deiner Organisation beeinflussen. " + }, + "autoConfirmWarningLink": { + "message": "Erfahre mehr über die Risiken" + }, "accountSecurity": { "message": "Kontosicherheit" }, @@ -5668,10 +5686,10 @@ "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Gefährdetes Passwort." }, "changeNow": { - "message": "Change now" + "message": "Jetzt ändern" }, "missingWebsite": { "message": "Fehlende Website" diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index e05cc5f4d6a..ea86a8137a8 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Κονσόλα Διαχειριστή" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Ασφάλεια λογαριασμού" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1e22c5ffa34..4b773754d24 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index cbb2851f872..d841974a386 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 1160899a4d3..fab3e5f5bd9 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Consola de administrador" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Seguridad de la cuenta" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 69fa7ef8bfc..674fc7f85e9 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index d5aeb2ce295..175dff36dce 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 510b71de2ee..770c83f1d0e 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "کنسول مدیر" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "امنیت حساب کاربری" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 011c5fb9026..3ad41bc0b78 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Hallintapaneelista" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Tilin suojaus" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 260449921bd..d6d34948cf4 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 0cd6abfee60..56604fa3a10 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Console Admin" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Sécurité du compte" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 642659da268..efd1396eac9 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Consola do administrador" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Seguridade da conta" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index b59499f1d6d..2b466187b70 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "מסוף מנהל" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "אבטחת החשבון" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 637c1943174..5458ff6f671 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index bef42a97294..d0a46669fd9 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Konzola administratora" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Sigurnost računa" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 915f2241efd..38cf27cfdc2 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Adminisztrátori konzol" }, + "admin": { + "message": "Adminisztrátor" + }, + "automaticUserConfirmation": { + "message": "Automatikus felhasználói megerősítés" + }, + "automaticUserConfirmationHint": { + "message": "A függőben lévő felhasználók automatikus megerősítése az eszköz zárolásának feloldásakor." + }, + "autoConfirmOnboardingCallout": { + "message": "Idő megtakarítás az automatikus felhasználói megerősítéssel" + }, + "autoConfirmWarning": { + "message": "Ez hatással lehet a szervezet adatbiztonságára." + }, + "autoConfirmWarningLink": { + "message": "További információ a kockázatokról" + }, "accountSecurity": { "message": "Fiókbiztonság" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 643b72125a2..9d50cc7f048 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Konsol Admin" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Keamanan akun" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index a74b4aa4757..2fd85c2096e 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -586,7 +586,7 @@ "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "L'elemento è stato ripristinato" }, "edit": { "message": "Modifica" @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Console di amministrazione" }, + "admin": { + "message": "Amministratore" + }, + "automaticUserConfirmation": { + "message": "Conferma automatica degli utenti" + }, + "automaticUserConfirmationHint": { + "message": "Conferma automaticamente gli utenti in sospeso mentre il dispositivo è sbloccato" + }, + "autoConfirmOnboardingCallout": { + "message": "Risparmia tempo con la conferma automatica degli utenti" + }, + "autoConfirmWarning": { + "message": "Potrebbe influenzare la sicurezza dei dati della tua organizzazione. " + }, + "autoConfirmWarningLink": { + "message": "Scopri quali sono i rischi" + }, "accountSecurity": { "message": "Sicurezza dell'account" }, @@ -5668,10 +5686,10 @@ "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Password vulnerabile." }, "changeNow": { - "message": "Change now" + "message": "Cambiala subito!" }, "missingWebsite": { "message": "Sito web mancante" @@ -6050,6 +6068,6 @@ "message": "Perché vedo questo avviso?" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Ridimensiona la navigazione laterale" } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 8233240d728..ed377335590 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "管理コンソール" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "アカウントのセキュリティ" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 7b79332d906..b54ca90d920 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "ანგარიშის უსაფრთხოება" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 6b5c8251bf1..ec287d84d9d 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 8841a307e5a..14c97b086fe 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "관리자 콘솔" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "계정 보안" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index bb95441b30a..44574d5bae8 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Administratoriaus konsolės" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Paskyros saugumas" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 9ef89e918b1..eca6a7f92af 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "pārvaldības konsolē," }, + "admin": { + "message": "Pārvaldītājs" + }, + "automaticUserConfirmation": { + "message": "Automātiska lietotāju apstiprināšana" + }, + "automaticUserConfirmationHint": { + "message": "Automātiski apstiprināt ierindotos lietotājus, kamēr šī ierīce ir atslēgta" + }, + "autoConfirmOnboardingCallout": { + "message": "Laika ietaupīšana ar automātisku lietotāju apstiprināšanu" + }, + "autoConfirmWarning": { + "message": "Tas varētu ietekmēt apvienības datu drošību. " + }, + "autoConfirmWarningLink": { + "message": "Uzzināt par riskiem" + }, "accountSecurity": { "message": "Konta drošība" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index bb025788e18..49afe2b7db5 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 8440297105c..db264f4d571 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index fcf1a3f14d9..c588aa0ef39 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Administrasjonskonsoll" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Kontosikkerhet" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ac465690dcd..d4bb4a8f6d9 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Beheerconsole" }, + "admin": { + "message": "Beheerder" + }, + "automaticUserConfirmation": { + "message": "Automatische gebruikersbevestiging" + }, + "automaticUserConfirmationHint": { + "message": "Automatisch gebruikers in behandeling bevestigen wanneer dit apparaat is ontgrendeld" + }, + "autoConfirmOnboardingCallout": { + "message": "Bespaar tijd met automatische gebruikersbevestiging" + }, + "autoConfirmWarning": { + "message": "Dit kan van invloed zijn op de gegevensbeveiliging van je organisatie. " + }, + "autoConfirmWarningLink": { + "message": "Meer informatie over de risico's" + }, "accountSecurity": { "message": "Accountbeveiliging" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 5162829669d..4392b744e97 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -586,7 +586,7 @@ "message": "A premium membership is required to use Archive." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element został przywrócony" }, "edit": { "message": "Edytuj" @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Konsola administratora" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Bezpieczeństwo konta" }, @@ -5671,7 +5689,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Zmień teraz" }, "missingWebsite": { "message": "Brak strony internetowej" diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 2a35a6f0c64..a21fefd54a5 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Painel de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática de usuários" + }, + "automaticUserConfirmationHint": { + "message": "Confirme automaticamente usuários pendentes quando este dispositivo for desbloqueado" + }, + "autoConfirmOnboardingCallout": { + "message": "Economize tempo com a confirmação automática de usuários" + }, + "autoConfirmWarning": { + "message": "Isso pode afetar a segurança dos dados da sua organização. " + }, + "autoConfirmWarningLink": { + "message": "Saiba mais sobre os riscos" + }, "accountSecurity": { "message": "Segurança da conta" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index fdf3ba2d164..69c97e5ad78 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Consola de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática do utilizador" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Segurança da conta" }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 44c4abba934..40faec2782a 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 2b96b2038a5..64e9c877c48 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "консоли администратора" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматическое подтверждение пользователя" + }, + "automaticUserConfirmationHint": { + "message": "Автоматически подтверждать ожидающих пользователей пока это устройство разблокировано" + }, + "autoConfirmOnboardingCallout": { + "message": "Экономьте время благодаря автоматическому подтверждению пользователей" + }, + "autoConfirmWarning": { + "message": "Это может повлиять на безопасность данных вашей организации. " + }, + "autoConfirmWarningLink": { + "message": "Узнайте о рисках" + }, "accountSecurity": { "message": "Безопасность аккаунта" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index c06249e55cb..79ff6de2618 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 9c7cdec8c8f..6b8d752a7fc 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Správcovská konzola" }, + "admin": { + "message": "Správca" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrdenie používateľa" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdzovať čakajúcich používateľov, keď je toto zariadenie odomknuté" + }, + "autoConfirmOnboardingCallout": { + "message": "Šetrite čas automatickým potvrdzovaním používateľa" + }, + "autoConfirmWarning": { + "message": "Môže mať vplyv na bezpečnosť údajov vašej organizácie. " + }, + "autoConfirmWarningLink": { + "message": "Dozvedieť sa viac o rizikách" + }, "accountSecurity": { "message": "Zabezpečenie účtu" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 7d9eef643a3..3f82082adf2 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 223d5909d41..59e1ce9335e 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Администраторска конзола" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Безбедност налога" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 4a9fc27dd84..81a7fda2e39 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -586,7 +586,7 @@ "message": "Ett premium-medlemskap krävs för att använda Arkiv." }, "itemRestored": { - "message": "Item has been restored" + "message": "Objektet har återställts" }, "edit": { "message": "Redigera" @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Adminkonsol" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Kontosäkerhet" }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index d11b2329b3f..db9e2f519af 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "நிர்வாகக் கன்சோல்" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "கணக்கு பாதுகாப்பு" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index ea4f2b08a85..26184ebf9c1 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index f12bed9ea18..53a2c56ae94 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "คอนโซลผู้ดูแลระบบ" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "ความปลอดภัยของบัญชี" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 84b240c2397..d2356eb133f 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Yönetici Konsolu" }, + "admin": { + "message": "Yönetici" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Hesap güvenliği" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 2688995d6a7..854c43872e5 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "консолі адміністратора," }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Безпека облікового запису" }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index e00aae84e50..df7d6c00d7b 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "Bảng điều khiển dành cho quản trị viên" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Bảo mật tài khoản" }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index ef2ac258078..1658ce23944 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "管理员" + }, + "automaticUserConfirmation": { + "message": "自动用户确认" + }, + "automaticUserConfirmationHint": { + "message": "当此设备已解锁时,自动确认待处理的用户" + }, + "autoConfirmOnboardingCallout": { + "message": "通过自动用户确认节省时间" + }, + "autoConfirmWarning": { + "message": "这可能会影响您组织的数据安全。" + }, + "autoConfirmWarningLink": { + "message": "了解此风险" + }, "accountSecurity": { "message": "账户安全" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b43739639c5..5a772b12476 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -4811,6 +4811,24 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "帳戶安全性" }, From c6f704bd219609d5630d5f105ee6350c77c30625 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Fri, 9 Jan 2026 10:37:16 -0500 Subject: [PATCH 069/101] [PM-29518] Remove @ts-strict-ignore in overlay/inline-menu/content/autofill-inline-menu-content.service.ts (#18155) * Initialized the observers directly in the constructor and removed setupMutationObserver * explicitly initialize timers as null * removed redundant checks for inlineMenuEnabled and tracked the button and list so TS knows they are definitely assigned * early returns for processContainerElementMutation list and button checks, last child now has a fallback to 0 for undefined * Update apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts Co-authored-by: Jonathan Prusik --------- Co-authored-by: Jonathan Prusik --- .../autofill-inline-menu-content.service.ts | 79 ++++++++----------- 1 file changed, 32 insertions(+), 47 deletions(-) diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index b61e5e19d53..c2f872d7ba5 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { InlineMenuElementPosition, InlineMenuPosition, @@ -62,8 +60,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte */ private inlineMenuEnabled = true; private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; - private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; + private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null; + private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout | null = null; private lastElementOverrides: WeakMap = new WeakMap(); private readonly customElementDefaultStyles: Partial = { all: "initial", @@ -77,7 +75,21 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; constructor() { - this.setupMutationObserver(); + /** + * Sets up mutation observers for the inline menu elements, the menu container, and + * the document element. The mutation observers are used to remove any styles that + * are added to the inline menu elements by the website. They are also used to ensure + * that the inline menu elements are always present at the bottom of the menu container. + */ + this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); + this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); + this.inlineMenuElementsMutationObserver = new MutationObserver( + this.handleInlineMenuElementMutationObserverUpdate, + ); + this.containerElementMutationObserver = new MutationObserver( + this.handleContainerElementMutationObserverUpdate, + ); + this.observePageAttributes(); } /** @@ -181,12 +193,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu button. */ private async appendButtonElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.buttonElement) { - this.createButtonElement(); + this.buttonElement = this.createButtonElement(); this.updateCustomElementDefaultStyles(this.buttonElement); } @@ -201,12 +209,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu list. */ private async appendListElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.listElement) { - this.createListElement(); + this.listElement = this.createListElement(); this.updateCustomElementDefaultStyles(this.listElement); } @@ -257,16 +261,12 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createButtonElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); this.buttonElement.setAttribute("popover", "manual"); new AutofillInlineMenuButtonIframe(this.buttonElement); - return; + return this.buttonElement; } const customElementName = this.generateRandomCustomElementName(); @@ -282,6 +282,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); + return this.buttonElement; } /** @@ -289,16 +290,12 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createListElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); this.listElement.setAttribute("popover", "manual"); new AutofillInlineMenuListIframe(this.listElement); - return; + return this.listElement; } const customElementName = this.generateRandomCustomElementName(); @@ -314,6 +311,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); + return this.listElement; } /** @@ -330,27 +328,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.observeCustomElements(); } - /** - * Sets up mutation observers for the inline menu elements, the menu container, and - * the document element. The mutation observers are used to remove any styles that - * are added to the inline menu elements by the website. They are also used to ensure - * that the inline menu elements are always present at the bottom of the menu container. - */ - private setupMutationObserver = () => { - this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); - this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); - - this.inlineMenuElementsMutationObserver = new MutationObserver( - this.handleInlineMenuElementMutationObserverUpdate, - ); - - this.containerElementMutationObserver = new MutationObserver( - this.handleContainerElementMutationObserverUpdate, - ); - - this.observePageAttributes(); - }; - /** * Sets up mutation observers to verify that the inline menu * elements are not modified by the website. @@ -652,6 +629,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return; } + if (!this.buttonElement) { + return; + } + const lastChild = containerElement.lastElementChild; const secondToLastChild = lastChild?.previousElementSibling; const lastChildIsInlineMenuList = lastChild === this.listElement; @@ -667,7 +648,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.lastElementOverrides.set(lastChild, lastChildEncounterCount + 1); } - if (this.lastElementOverrides.get(lastChild) >= 3) { + const lastChildEncounterCountAfterUpdate = this.lastElementOverrides.get(lastChild) || 0; + if (lastChildEncounterCountAfterUpdate >= 3) { this.handlePersistentLastChildOverride(lastChild); return; @@ -686,6 +668,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte (lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) || (lastChildIsInlineMenuButton && isInlineMenuListVisible) ) { + if (!this.listElement) { + return; + } containerElement.insertBefore(this.buttonElement, this.listElement); return; } From 392794b560cdc598c322a99930eff0dc50a9253f Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 9 Jan 2026 12:41:39 -0500 Subject: [PATCH 070/101] Ac/pm 26365 auto confirm extension one time setup dialog (#17339) * create nav link for auto confirm in settings page * wip * WIP * create auto confirm library * migrate auto confirm files to lib * update imports * fix tests * fix nudge * cleanup, add documentation * clean up * cleanup * fix import * fix more imports * implement one time dialog * add tests * design changes * fix styles * edit copy * fix tests * fix tw issue * fix typo, add tests * CR feedback * more clean up, fix race condition * CR feedback, cache policies, refactor tests * run prettier with updated version * clean up duplicate logic * clean up * add missing export * fix test * fix dialog position * add tests --- apps/browser/src/_locales/en/messages.json | 15 +++ .../vault-v2/vault-v2.component.spec.ts | 103 ++++++++++++++++++ .../components/vault-v2/vault-v2.component.ts | 39 +++++++ ...auto-confirm-extension-dialog.component.ts | 78 +++++++++++++ .../auto-confirm-warning-dialog.component.ts | 11 +- libs/auto-confirm/src/components/index.ts | 1 + 6 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29b39863bc6..1613373bd62 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4829,6 +4829,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 883d17b61c3..e6dffdaff08 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -10,6 +10,10 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component"; import AutofillService from "@bitwarden/browser/autofill/services/autofill.service"; import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; @@ -136,6 +140,7 @@ class VaultListItemsContainerStubComponent { const mockDialogRef = { close: jest.fn(), afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), } as unknown as import("@bitwarden/components").DialogRef; jest @@ -145,6 +150,11 @@ jest jest .spyOn(DecryptionFailureDialogComponent, "open") .mockImplementation((_: DialogService, _params: any) => mockDialogRef as any); + +const autoConfirmDialogSpy = jest + .spyOn(AutoConfirmExtensionSetupDialogComponent, "open") + .mockImplementation((_: DialogService) => mockDialogRef as any); + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); @@ -222,6 +232,13 @@ describe("VaultV2Component", () => { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; + const autoConfirmSvc = { + configuration$: jest.fn().mockReturnValue(of({})), + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + upsert: jest.fn().mockResolvedValue(undefined), + autoConfirmUser: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ @@ -275,6 +292,10 @@ describe("VaultV2Component", () => { provide: SearchService, useValue: { isCipherSearching$: of(false) }, }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmSvc, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -588,4 +609,86 @@ describe("VaultV2Component", () => { const spotlights = queryAllSpotlights(fixture); expect(spotlights.length).toBe(0); })); + + describe("AutoConfirmExtensionSetupDialog", () => { + beforeEach(() => { + autoConfirmDialogSpy.mockClear(); + }); + + it("opens dialog when canManage is true and showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).toHaveBeenCalledWith(expect.any(Object)); + })); + + it("does not open dialog when showBrowserNotification is false", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when showBrowserNotification is true", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: true, + showSetupDialog: true, + showBrowserNotification: true, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when canManage is false even if showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(false)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 30d1d21abfb..761b366bcd2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -15,6 +15,7 @@ import { shareReplay, switchMap, take, + withLatestFrom, tap, BehaviorSubject, } from "rxjs"; @@ -25,6 +26,11 @@ import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutoConfirmState, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -41,6 +47,7 @@ import { ButtonModule, DialogService, NoItemsModule, + ToastService, TypographyModule, } from "@bitwarden/components"; import { @@ -267,6 +274,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, + private autoConfirmService: AutomaticUserConfirmationService, + private toastService: ToastService, private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, @@ -329,6 +338,36 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); }); + const autoConfirmState$ = this.autoConfirmService.configuration$(this.activeUserId); + + combineLatest([ + this.autoConfirmService.canManageAutoConfirm$(this.activeUserId), + autoConfirmState$, + ]) + .pipe( + filter(([canManage, state]) => canManage && state.showBrowserNotification === undefined), + take(1), + switchMap(() => AutoConfirmExtensionSetupDialogComponent.open(this.dialogService).closed), + withLatestFrom(autoConfirmState$, this.accountService.activeAccount$.pipe(getUserId)), + switchMap(([result, state, userId]) => { + const newState: AutoConfirmState = { + ...state, + enabled: result ?? false, + showBrowserNotification: !result, + }; + + if (result) { + this.toastService.showToast({ + message: this.i18nService.t("autoConfirmEnabled"), + variant: "success", + }); + } + + return this.autoConfirmService.upsert(userId, newState); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId); this.readySubject.next(true); diff --git a/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts new file mode 100644 index 00000000000..c04d8b5209b --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts @@ -0,0 +1,78 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + BadgeComponent, + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+
+ {{ "availableNow" | i18n }} +
+
+

+ + {{ "autoConfirmSetup" | i18n }} + +

+ + {{ "autoConfirmSetupDesc" | i18n }} + +
+
+ +
+ + + + + {{ "autoConfirmSetupHint" | i18n }} + + + +
+
+
+ `, + imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent], +}) +export class AutoConfirmExtensionSetupDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmExtensionSetupDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); + } +} diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts index f126ce3b92c..877a0fe918a 100644 --- a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -2,7 +2,12 @@ import { DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ @@ -14,6 +19,8 @@ export class AutoConfirmWarningDialogComponent { constructor(public dialogRef: DialogRef) {} static open(dialogService: DialogService) { - return dialogService.open(AutoConfirmWarningDialogComponent); + return dialogService.open(AutoConfirmWarningDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } } diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts index a0310e805c6..1cddd1d7e59 100644 --- a/libs/auto-confirm/src/components/index.ts +++ b/libs/auto-confirm/src/components/index.ts @@ -1 +1,2 @@ +export * from "./auto-confirm-extension-dialog.component"; export * from "./auto-confirm-warning-dialog.component"; From 1b76ce5b7c19703429de6ec0b6f8d831c688d9df Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:31:08 -0800 Subject: [PATCH 071/101] [PM-30264] - fix exact match dialog show logic (#18216) * fix exact match dialog show logic * fix logic for uri matching * simplify exact match dialog show logic --- .../item-more-options.component.spec.ts | 187 ++++++++++-------- .../item-more-options.component.ts | 11 +- 2 files changed, 111 insertions(+), 87 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index bd9ce108522..6728249b788 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -158,14 +158,6 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); - it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - uriMatchStrategy$.next(UriMatchStrategy.Exact); - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - describe("autofill confirmation dialog", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Domain); @@ -236,22 +228,30 @@ describe("ItemMoreOptionsComponent", () => { }); describe("URI match strategy handling", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + describe("when the default URI match strategy is Exact", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Exact); }); - it("calls the passwordService to passwordRepromptCheck", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); - - await component.doAutofill(); - - expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); - }); - - it("shows the exact match dialog", async () => { + it("shows the exact match dialog when the cipher has no saved URIs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); await component.doAutofill(); @@ -266,6 +266,53 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); + + it("does not show the exact match dialog when the cipher has at least one non-exact match uri", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher uris all have a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); }); describe("when the default URI match strategy is not Exact", () => { @@ -273,7 +320,45 @@ describe("ItemMoreOptionsComponent", () => { mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); uriMatchStrategy$.next(UriMatchStrategy.Domain); }); - it("does not show the exact match dialog", async () => { + + it("does not show the exact match dialog when the cipher has no saved URIs", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has only exact match saved URIs", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has at least one uri without a match strategy of Exact", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c, @@ -292,70 +377,6 @@ describe("ItemMoreOptionsComponent", () => { expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); - - it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.objectContaining({ key: "cannotAutofill" }), - content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), - type: "info", - }), - ); - expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - }); - }); - - it("does not show the exact match dialog when the cipher has no uris", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - - it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [ - { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, - { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, - ], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index c4353e17bef..ce797d9755e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -204,12 +204,15 @@ export class ItemMoreOptionsComponent { } const uris = cipher.login?.uris ?? []; - const cipherHasAllExactMatchLoginUris = - uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, From 92190d734c84c4d545c8fe147c2586984969bac7 Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Fri, 9 Jan 2026 13:39:26 -0500 Subject: [PATCH 072/101] Pm 28182 add success page (#17814) * PM-28182-implemented send confirmation drawer * PM-28182 resolved lint issue * PM-28182 resolved pr comment * PM-28182 put behind feature flag * Fix feature flag checks in send component * Fix feature flag checks in send dropdown component * Add SendUIRefresh feature flag * PM-28182 resolved lint issues * PM-28182 resolved N bug in drawer message * PM28182 resolved expirationDate replaced with delettionDate * PM-28182 resolved build issue * PM-28182 resolved failling tests * PM-28182 resolved pr comment to consolidate expression * chore: rerun web build * PM-28182 removed unneeded export --- .../new-send-dropdown.component.spec.ts | 3 + .../new-send/new-send-dropdown.component.ts | 23 ++++-- apps/web/src/app/tools/send/send.component.ts | 18 ++++- apps/web/src/app/tools/send/shared/index.ts | 1 + .../send-success-drawer-dialog.component.html | 45 +++++++++++ .../send-success-drawer-dialog.component.ts | 75 +++++++++++++++++++ apps/web/src/locales/en/messages.json | 33 +++++++- .../send-add-edit-dialog.component.ts | 12 +-- 8 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/app/tools/send/shared/index.ts create mode 100644 apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html create mode 100644 apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts index 4f5dda1745e..134eaac2956 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts @@ -72,6 +72,7 @@ describe("NewSendDropdownComponent", () => { const openSpy = jest.spyOn(SendAddEditDialogComponent, "open"); const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer"); mockConfigService.getFeatureFlag.mockResolvedValue(false); + openSpy.mockReturnValue({ closed: of({}) } as any); await component.createSend(SendType.Text); @@ -85,6 +86,8 @@ describe("NewSendDropdownComponent", () => { mockConfigService.getFeatureFlag.mockImplementation(async (key) => key === FeatureFlag.SendUIRefresh ? true : false, ); + const mockRef = { closed: of({}) }; + openDrawerSpy.mockReturnValue(mockRef as any); await component.createSend(SendType.Text); diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index 22f07e4fe92..dca70dca4b8 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; -import { firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { firstValueFrom, Observable, of, switchMap, lastValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -10,7 +10,13 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; -import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; +import { + DefaultSendFormConfigService, + SendAddEditDialogComponent, + SendItemDialogResult, +} from "@bitwarden/send-ui"; + +import { SendSuccessDrawerDialogComponent } from "../shared"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -60,12 +66,19 @@ export class NewSendDropdownComponent { if (!(await firstValueFrom(this.canAccessPremium$)) && type === SendType.File) { return; } - const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type); - const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh); + if (useRefresh) { - SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + if (dialogRef) { + const result = await lastValueFrom(dialogRef.closed); + if (result?.result === SendItemDialogResult.Saved && result?.send) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } } else { SendAddEditDialogComponent.open(this.dialogService, { formConfig }); } diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index 7c0e03e3e21..eb3d92ebe26 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -39,6 +39,7 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; import { NewSendDropdownComponent } from "./new-send/new-send-dropdown.component"; +import { SendSuccessDrawerDialogComponent } from "./shared"; const BroadcasterSubscriptionId = "SendComponent"; @@ -172,12 +173,25 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro }); } - const result = await lastValueFrom(this.sendItemDialogRef.closed); + const result: SendItemDialogResult = await lastValueFrom(this.sendItemDialogRef.closed); this.sendItemDialogRef = undefined; // If the dialog was closed by deleting the cipher, refresh the vault. - if (result === SendItemDialogResult.Deleted || result === SendItemDialogResult.Saved) { + if ( + result?.result === SendItemDialogResult.Deleted || + result?.result === SendItemDialogResult.Saved + ) { await this.load(); } + + if ( + result?.result === SendItemDialogResult.Saved && + result?.send && + (await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh)) + ) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } } } diff --git a/apps/web/src/app/tools/send/shared/index.ts b/apps/web/src/app/tools/send/shared/index.ts new file mode 100644 index 00000000000..afc507ee464 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/index.ts @@ -0,0 +1 @@ +export { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html new file mode 100644 index 00000000000..b9326ca08ac --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -0,0 +1,45 @@ + + + {{ dialogTitle() | i18n }} + + +
+
+
+ +
+
+ +

+ {{ "sendCreatedSuccessfully" | i18n }} +

+ +

+ {{ "sendCreatedDescription" | i18n: formattedExpirationTime }} +

+ + + {{ "sendLink" | i18n }} + + + +
+
+ + + + + +
diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts new file mode 100644 index 00000000000..1cea9b83428 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { ActiveSendIcon } from "@bitwarden/assets/svg"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +@Component({ + imports: [SharedModule, DialogModule, TypographyModule], + templateUrl: "./send-success-drawer-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendSuccessDrawerDialogComponent { + readonly sendLink = signal(""); + activeSendIcon = ActiveSendIcon; + + // Computed property to get the dialog title based on send type + readonly dialogTitle = computed(() => { + return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; + }); + + constructor( + @Inject(DIALOG_DATA) public send: SendView, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) { + void this.initLink(); + } + + async initLink() { + const env = await firstValueFrom(this.environmentService.environment$); + this.sendLink.set(env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key); + } + + get formattedExpirationTime(): string { + if (!this.send.deletionDate) { + return ""; + } + const hoursAvailable = this.getHoursAvailable(this.send); + if (hoursAvailable < 24) { + return hoursAvailable === 1 + ? this.i18nService.t("oneHour").toLowerCase() + : this.i18nService.t("durationTimeHours", String(hoursAvailable)).toLowerCase(); + } + const daysAvailable = Math.ceil(hoursAvailable / 24); + return daysAvailable === 1 + ? this.i18nService.t("oneDay").toLowerCase() + : this.i18nService.t("days", String(daysAvailable)).toLowerCase(); + } + + private getHoursAvailable(send: SendView): number { + const now = new Date().getTime(); + const deletionDate = new Date(send.deletionDate).getTime(); + return Math.max(0, Math.ceil((deletionDate - now) / (1000 * 60 * 60))); + } + + copyLink() { + const link = this.sendLink(); + if (!link) { + return; + } + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")), + }); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8024de21e56..5952abef7fc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -12581,4 +12612,4 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } -} +} \ No newline at end of file diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 38257df603a..d2f2c2204b9 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({ } as const); /** A result of the Send add/edit dialog. */ -export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; - +export type SendItemDialogResult = { + result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; + send?: SendView; +}; /** * Component for adding or editing a send item. */ @@ -93,7 +95,7 @@ export class SendAddEditDialogComponent { */ async onSendCreated(send: SendView) { // FIXME Add dialogService.open send-created dialog - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved, send }); return; } @@ -101,14 +103,14 @@ export class SendAddEditDialogComponent { * Handles the event when the send is updated. */ async onSendUpdated(send: SendView) { - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved }); } /** * Handles the event when the send is deleted. */ async onSendDeleted() { - this.dialogRef.close(SendItemDialogResult.Deleted); + this.dialogRef.close({ result: SendItemDialogResult.Deleted }); this.toastService.showToast({ variant: "success", From 881afacdede0b907ba3b7ad0aa2a23f367461e80 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 9 Jan 2026 14:18:17 -0600 Subject: [PATCH 073/101] Enable cross-compilation and packaging of Windows Appx from macOS (#17976) * Enable cross-compilation and packaging of Windows Appx from macOS * Consolidate cargo build execution into a single function in native build script * Install cargo-xwin when needed * Install Appx tools when needed * Consolidate command execution into a single function in native build script * Only include the native node modules for the appropriate platform electron-builder's globs interact strangely, so we can't exclude all the .node files in the global config and then include the platform-specific files in the platform configuration. * Always copy Rust binaries to dist folder * Log source and destination when copying files * Update copyright * Match Electron version in Beta build --- apps/desktop/custom-appx-manifest.xml | 111 +++++++++++ apps/desktop/desktop_native/build.js | 120 ++++++++---- apps/desktop/electron-builder.beta.json | 18 +- apps/desktop/electron-builder.json | 16 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/after-pack.js | 5 +- apps/desktop/scripts/appx-cross-build.ps1 | 226 ++++++++++++++++++++++ apps/desktop/scripts/before-pack.js | 31 +++ 8 files changed, 474 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/custom-appx-manifest.xml create mode 100755 apps/desktop/scripts/appx-cross-build.ps1 create mode 100644 apps/desktop/scripts/before-pack.js diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml new file mode 100644 index 00000000000..2f7796c97cf --- /dev/null +++ b/apps/desktop/custom-appx-manifest.xml @@ -0,0 +1,111 @@ + + + + + + + + ${displayName} + ${publisherDisplayName} + A secure and free password manager for all of your devices. + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 54a6dba8326..b20aa7e5af8 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -20,47 +20,79 @@ fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true }); const args = process.argv.slice(2); // Get arguments passed to the script const mode = args.includes("--release") ? "release" : "debug"; +const isRelease = mode === "release"; const targetArg = args.find(arg => arg.startsWith("--target=")); const target = targetArg ? targetArg.split("=")[1] : null; let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platform"; +/** + * Execute a command. + * @param {string} bin Executable to run. + * @param {string[]} args Arguments for executable. + * @param {string} [workingDirectory] Path to working directory, relative to the script directory. Defaults to the script directory. + * @param {string} [useShell] Whether to use a shell to execute the command. Defaults to false. + */ +function runCommand(bin, args, workingDirectory = "", useShell = false) { + const options = { stdio: 'inherit', cwd: path.resolve(__dirname, workingDirectory), shell: useShell } + console.debug("Running command:", bin, args, options) + child_process.execFileSync(bin, args, options) +} + function buildNapiModule(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; + const targetArg = target ? `--target=${target}` : ""; const releaseArg = release ? "--release" : ""; - child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") }); + const crossCompileArg = effectivePlatform(target) !== process.platform ? "--cross-compile" : ""; + runCommand("npm", ["run", "build", "--", crossCompileArg, releaseArg, targetArg].filter(s => s != ''), "./napi", true); +} + +/** + * Build a Rust binary with Cargo. + * + * If {@link target} is specified, cross-compilation helpers are used to build if necessary, and the resulting + * binary is copied to the `dist` folder. + * @param {string} bin Name of cargo binary package in `desktop_native` workspace. + * @param {string?} target Rust compiler target, e.g. `aarch64-pc-windows-msvc`. + * @param {boolean} release Whether to build in release mode. + */ +function cargoBuild(bin, target, release) { + const targetArg = target ? `--target=${target}` : ""; + const releaseArg = release ? "--release" : ""; + const args = ["build", "--bin", bin, releaseArg, targetArg] + // Use cross-compilation helper if necessary + if (effectivePlatform(target) === "win32" && process.platform !== "win32") { + args.unshift("xwin") + } + runCommand("cargo", args.filter(s => s != '')) + + // Infer the architecture and platform if not passed explicitly + let nodeArch, platform; + if (target) { + nodeArch = rustTargetsMap[target].nodeArch; + platform = rustTargetsMap[target].platform; + } + else { + nodeArch = process.arch; + platform = process.platform; + } + + // Copy the resulting binary to the dist folder + const profileFolder = isRelease ? "release" : "debug"; + const ext = platform === "win32" ? ".exe" : ""; + const src = path.join(__dirname, "target", target ? target : "", profileFolder, `${bin}${ext}`) + const dst = path.join(__dirname, "dist", `${bin}.${platform}-${nodeArch}${ext}`) + console.log(`Copying ${src} to ${dst}`); + fs.copyFileSync(src, dst); } function buildProxyBin(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const ext = process.platform === "win32" ? ".exe" : ""; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`)); - } + cargoBuild("desktop_proxy", target, release) } function buildImporterBinaries(target, release = true) { // These binaries are only built for Windows, so we can skip them on other platforms - if (process.platform !== "win32") { - return; - } - - const bin = "bitwarden_chromium_import_helper"; - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + if (effectivePlatform(target) == "win32") { + cargoBuild("bitwarden_chromium_import_helper", target, release) } } @@ -69,17 +101,29 @@ function buildProcessIsolation() { return; } - child_process.execSync(`cargo build --release`, { - stdio: 'inherit', - cwd: path.join(__dirname, "process_isolation") - }); + runCommand("cargo", ["build", "--package", "process_isolation", "--release"]); console.log("Copying process isolation library to dist folder"); fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); } function installTarget(target) { - child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); + runCommand("rustup", ["target", "add", target]); + // Install cargo-xwin for cross-platform builds targeting Windows + if (target.includes('windows') && process.platform !== 'win32') { + runCommand("cargo", ["install", "--version", "0.20.2", "--locked", "cargo-xwin"]); + // install tools needed for packaging Appx, only supported on macOS for now. + if (process.platform === "darwin") { + runCommand("brew", ["install", "iinuwa/msix-packaging-tap/msix-packaging", "osslsigncode"]); + } + } +} + +function effectivePlatform(target) { + if (target) { + return rustTargetsMap[target].platform + } + return process.platform } if (!crossPlatform && !target) { @@ -94,9 +138,9 @@ if (!crossPlatform && !target) { if (target) { console.log(`Building for target: ${target} in ${mode} mode`); installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(false, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); return; } @@ -113,8 +157,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(target, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); }); diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 0c95c7f01a6..2d7d76827f1 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -13,14 +13,15 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -34,11 +35,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -58,6 +59,7 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index a4e1c44dc5b..c42c3cc4202 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -13,12 +13,13 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], "electronVersion": "39.2.6", "generateUpdatesFilesForAllChannels": true, @@ -94,11 +95,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -172,6 +173,7 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17322c42a84..93d016f8791 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 5fc42f31ac3..34378ee092b 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -6,9 +6,12 @@ const path = require("path"); const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); const builder = require("electron-builder"); const fse = require("fs-extra"); - exports.default = run; +/** + * + * @param {builder.AfterPackContext} context + */ async function run(context) { console.log("## After pack"); // console.log(context); diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 new file mode 100755 index 00000000000..62619d5ea37 --- /dev/null +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -0,0 +1,226 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Script to build, package and sign the Bitwarden desktop client as a Windows Appx +package. + +.DESCRIPTION +This script provides cross-platform support for packaging and signing the +Bitwarden desktop client as a Windows Appx package. + +Currently, only macOS -> Windows Appx is supported, but Linux -> Windows Appx +could be added in the future by providing Linux binaries for the msix-packaging +project. + +.NOTES +The reason this script exists is because electron-builder does not currently +support cross-platform Appx packaging without proprietary tools (Parallels +Windows VM). This script uses third-party tools (makemsix from msix-packaging +and osslsigncode) to package and sign the Appx. + +The signing certificate must have the same subject as the publisher name. This +can be generated on the Windows target using PowerShell 5.1 and copied to the +host, or directly on the host with OpenSSL. + +Using Windows PowerShell 5.1: +```powershell +$publisher = "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US" +$certificate = New-SelfSignedCertificate -Type Custom -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -Subject $publisher -FriendlyName "Bitwarden Developer Signing Certificate" +$password = Read-Host -AsSecureString +Export-PfxCertificate -cert "Cert:\CurrentUser\My\${$certificate.Thumbprint}" -FilePath "C:\path/to/pfx" -Password $password +``` + +Using OpenSSL: +```sh +subject="jurisdictionCountryName=US/jurisdictionStateOrProvinceName=Delaware/businessCategory=Private Organization/serialNumber=7654941, C=US, ST=California, L=Santa Barbara, O=Bitwarden Inc., CN=Bitwarden Inc." +keyfile="/tmp/mysigning.rsa.pem" +certfile="/tmp/mysigning.cert.pem" +p12file="/tmp/mysigning.p12" +openssl req -x509 -keyout "$keyfile" -out "$certfile" -subj "$subject" \ + -newkey rsa:2048 -days 3650 -nodes \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'extendedKeyUsage=critical,codeSigning' \ + -addext 'basicConstraints=critical,CA:FALSE' +openssl pkcs12 -inkey "$keyfile" -in "$certfile" -export -out "$p12file" +rm $keyfile +``` + +.EXAMPLE +./scripts/cross-build.ps1 -Architecture arm64 -CertificatePath ~/Development/code-signing.pfx -CertificatePassword (Read-Host -AsSecureString) -Release -Beta + +Reads the signing certificate password from user input, then builds, packages +and signs the Appx. + +Alternatively, you can specify the CERTIFICATE_PASSWORD environment variable. +#> +param( + [Parameter(Mandatory=$true)] + [ValidateSet("X64", "ARM64")]$Architecture, + [string] + # Path to PKCS12 certificate file. If not specified, the Appx will not be signed. + $CertificatePath, + [SecureString] + # Password for PKCS12 certificate. Alternatively, may be specified in + # CERTIFICATE_PASSWORD environment variable. If not specified, the Appx will + # not be signed. + $CertificatePassword, + [Switch] + # Whether to build the Beta version of the app. + $Beta=$false, + [Switch] + # Whether to build in release mode. + $Release=$false +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +$startTime = Get-Date +$originalLocation = Get-Location +if (!(Get-Command makemsix -ErrorAction SilentlyContinue)) { + Write-Error "The `makemsix` tool from the msix-packaging project is required to construct Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install iinuwa/msix-packaging-tap/msix-packaging" + Exit 1 +} + +if (!(Get-Command osslsigncode -ErrorAction SilentlyContinue)) { + Write-Error "The `osslsigncode` tool is required to sign the Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install osslsigncode" + Exit 1 +} + +if (!(Get-Command cargo-xwin -ErrorAction SilentlyContinue)) { + Write-Error "The `cargo-xwin` tool is required to cross-compile Windows native code." + Write-Error "You can install with cargo:" + Write-Error " cargo install --version 0.20.2 --locked cargo-xwin" + Exit 1 +} + +try { + +# Resolve certificate file before we change directories. +$CertificateFile = Get-Item $CertificatePath -ErrorAction SilentlyContinue + +cd $PSScriptRoot/.. + +if ($Beta) { + $electronConfigFile = Get-Item "./electron-builder.beta.json" +} +else { + $electronConfigFile = Get-Item "./electron-builder.json" +} + +$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json +$packageConfig = Get-Content package.json | ConvertFrom-Json +$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath + +$srcDir = Get-Location +$assetsDir = Get-Item $builderConfig.directories.buildResources +$buildDir = Get-Item $builderConfig.directories.app +$outDir = Join-Path (Get-Location) ($builderConfig.directories.output ?? "dist") + +if ($Release) { + $buildConfiguration = "--release" +} +$arch = "$Architecture".ToLower() +$ext = "appx" +$version = Get-Date -Format "yyyy.M.d.1HHmm" +$productName = $builderConfig.productName +$artifactName = "${productName}-$($packageConfig.version)-${arch}.$ext" + +Write-Host "Building native code" +$rustTarget = switch ($arch) { + x64 { "x86_64-pc-windows-msvc" } + arm64 { "aarch64-pc-windows-msvc" } + default { + Write-Error "Unsupported architecture: $Architecture. Supported architectures are x64 and arm64" + Exit(1) + } +} +npm run build-native -- cross-platform $buildConfiguration "--target=$rustTarget" + +Write-Host "Building Javascript code" +if ($Release) { + npm run build +} +else { + npm run build:dev +} + +Write-Host "Cleaning output folder" +Remove-Item -Recurse -Force $outDir -ErrorAction Ignore + +Write-Host "Packaging Electron executable" +& npx electron-builder --config $electronConfigFile --publish never --dir --win --$arch + +cd $outDir +New-Item -Type Directory (Join-Path $outDir "appx") + +Write-Host "Building Appx directory structure" +$appxDir = (Join-Path $outDir appx/app) +if ($arch -eq "x64") { + Move-Item (Join-Path $outDir "win-unpacked") $appxDir +} +else { + Move-Item (Join-Path $outDir "win-${arch}-unpacked") $appxDir +} + +Write-Host "Copying Assets" +New-Item -Type Directory (Join-Path $outDir appx/assets) +Copy-Item $srcDir/resources/appx/* $outDir/appx/assets/ + +Write-Host "Building Appx manifest" +$translationMap = @{ + 'arch' = $arch + 'applicationId' = $builderConfig.appx.applicationId + 'displayName' = $productName + 'executable' = "app\${productName}.exe" + 'publisher' = $builderConfig.appx.publisher + 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName + 'version' = $version +} + +$manifest = $manifestTemplate +$translationMap.Keys | ForEach-Object { + $manifest = $manifest.Replace("`${$_}", $translationMap[$_]) +} +$manifest | Out-File appx/AppxManifest.xml +$unsignedArtifactpath = [System.IO.Path]::GetFileNameWithoutExtension($artifactName) + "-unsigned.$ext" +Write-Host "Creating unsigned Appx" +makemsix pack -d appx -p $unsignedArtifactpath + +$outfile = Join-Path $outDir $unsignedArtifactPath +if ($null -eq $CertificatePath) { + Write-Warning "No Certificate specified. Not signing Appx." +} +elseif ($null -eq $CertificatePassword -and $null -eq $env:CERTIFICATE_PASSWORD) { + Write-Warning "No certificate password specified in CertificatePassword argument nor CERTIFICATE_PASSWORD environment variable. Not signing Appx." +} +else { + $cert = $CertificateFile + $pw = $null + if ($null -ne $CertificatePassword) { + $pw = ConvertFrom-SecureString -SecureString $CertificatePassword -AsPlainText + } else { + $pw = $env:CERTIFICATE_PASSWORD + } + $unsigned = $outfile + $outfile = (Join-Path $outDir $artifactName) + Write-Host "Signing $artifactName with $cert" + osslsigncode sign ` + -pkcs12 "$cert" ` + -pass "$pw" ` + -in $unsigned ` + -out $outfile + Remove-Item $unsigned +} + +$endTime = Get-Date +$elapsed = $endTime - $startTime +Write-Host "Successfully packaged $(Get-Item $outfile)" +Write-Host ("Finished at $($endTime.ToString('HH:mm:ss')) in $($elapsed.ToString('mm')) minutes and $($elapsed.ToString('ss')).$($elapsed.ToString('fff')) seconds") +} +finally { + Set-Location -Path $originalLocation +} diff --git a/apps/desktop/scripts/before-pack.js b/apps/desktop/scripts/before-pack.js new file mode 100644 index 00000000000..ca9bf924b2d --- /dev/null +++ b/apps/desktop/scripts/before-pack.js @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ +/** @import { BeforePackContext } from 'app-builder-lib' */ +exports.default = run; + +/** + * @param {BeforePackContext} context + */ +async function run(context) { + console.log("## before pack"); + console.log("Stripping .node files that don't belong to this platform..."); + removeExtraNodeFiles(context); +} + +/** + * Removes Node files for platforms besides the current platform being packaged. + * + * @param {BeforePackContext} context + */ +function removeExtraNodeFiles(context) { + // When doing cross-platform builds, due to electron-builder limitiations, + // .node files for other platforms may be generated and unpacked, so we + // remove them manually here before signing and distributing. + const packagerPlatform = context.packager.platform.nodeName; + const platforms = ["darwin", "linux", "win32"]; + const fileFilter = context.packager.info._configuration.files[0].filter; + for (const platform of platforms) { + if (platform != packagerPlatform) { + fileFilter.push(`!node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-*.node`); + } + } +} From eb12758c993f4655d1768d33b53f3a79ba1a64f3 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Fri, 9 Jan 2026 15:22:54 -0500 Subject: [PATCH 074/101] fix(nx): use relative paths in tsconfig.base.json for TypeScript 7 compatibility (#18295) Update the NX library generator to prefix paths with './' when adding entries to tsconfig.base.json. This ensures compatibility with TypeScript 7 and tsgo, which require relative paths to explicitly start with './'. --- libs/nx-plugin/src/generators/basic-lib.spec.ts | 2 +- libs/nx-plugin/src/generators/basic-lib.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/nx-plugin/src/generators/basic-lib.spec.ts b/libs/nx-plugin/src/generators/basic-lib.spec.ts index 9fd7a702375..2018593046b 100644 --- a/libs/nx-plugin/src/generators/basic-lib.spec.ts +++ b/libs/nx-plugin/src/generators/basic-lib.spec.ts @@ -24,7 +24,7 @@ describe("basic-lib generator", () => { expect(tsconfigContent).not.toBeNull(); const tsconfig = JSON.parse(tsconfigContent?.toString() ?? ""); expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([ - `libs/test/src/index.ts`, + `./libs/test/src/index.ts`, ]); }); diff --git a/libs/nx-plugin/src/generators/basic-lib.ts b/libs/nx-plugin/src/generators/basic-lib.ts index 4f2f542ac89..c0d8a528841 100644 --- a/libs/nx-plugin/src/generators/basic-lib.ts +++ b/libs/nx-plugin/src/generators/basic-lib.ts @@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) { updateJson(tree, "tsconfig.base.json", (json) => { const paths = json.compilerOptions.paths || {}; - paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`]; + paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`]; json.compilerOptions.paths = paths; return json; From 00882c331a43580343742fe4f80d029cdc61fa7a Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Fri, 9 Jan 2026 15:39:18 -0500 Subject: [PATCH 075/101] [PM-30611] show deleted archived items in trash (#18272) --- libs/angular/src/vault/components/vault-items.component.ts | 7 ++++++- .../src/vault/vault-filter/models/vault-filter.model.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 0254ddabf2b..563fd48028d 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -194,7 +194,12 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter], + [ + filter, + this.deletedFilter, + ...(this.deleted ? [] : [this.archivedFilter]), + restrictedTypeFilter, + ], allCiphers, ); }), diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 87536036644..83693c85239 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -51,7 +51,8 @@ export class VaultFilter { cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher); } if (this.status === "archive" && cipherPassesFilter) { - cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher); + cipherPassesFilter = + CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); } if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; From 711036bd6027cd190e700d8ad5c600bc507cfbe5 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:53:48 -0600 Subject: [PATCH 076/101] [PM-27325] Deprecate user account crypto init methods (#18188) * deprecate account crypto init methods * Add deprecation notice for new use cases on makeKeyPair --- .../src/abstractions/key.service.ts | 17 +++++++---------- libs/key-management/src/key.service.ts | 5 ----- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 6cf44544422..bc065155fdb 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -128,18 +128,13 @@ export abstract class KeyService { /** * Generates a new user key - * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. + * @deprecated Interacting with the master key directly is prohibited. + * For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team. * @throws Error when master key is null or undefined. * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; - /** - * Generates a new user key for a V1 user - * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. - * @returns A new user key - */ - abstract makeUserKeyV1(): Promise; /** * Clears the user's stored version of the user key * @param userId The desired user @@ -334,9 +329,9 @@ export abstract class KeyService { abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise; /** * Generates a new keypair - * @param key A key to encrypt the private key with. If not provided, - * defaults to the user key - * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + * @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK. + * @param key A symmetric key to wrap the newly created private key with. + * @returns A new keypair: [publicKey in Base64, wrapped privateKey] * @throws If the provided key is a null-ish value. */ abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>; @@ -361,6 +356,8 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @deprecated New use cases for cryptography initialization should be done in the SDK. + * Current usage is actively being migrated see PM-21771 for details. * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key * @throws An error if the userId is null or undefined. diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 8cb072a4c2a..752a89e5fcd 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -213,11 +213,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async makeUserKeyV1(): Promise { - const newUserKey = await this.keyGenerationService.createKey(512); - return newUserKey as UserKey; - } - /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user From a199744e2456fde1863dba0d89320ac659d04e32 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:56:36 -0800 Subject: [PATCH 077/101] Inform user if Desktop client already running (#17846) --- apps/desktop/src/main/window.main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index bbdd2ad0a0f..b2008d57bcd 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import * as path from "path"; import * as url from "url"; -import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron"; import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -122,6 +122,7 @@ export class WindowMain { if (!isMacAppStore()) { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { + dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running."); app.quit(); return; } else { From 494a4a59322fd05f3ab80c31e8ea06a78f11a914 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 9 Jan 2026 15:24:16 -0600 Subject: [PATCH 078/101] Allow local Electron app signing for Windows dev builds [PM-18325] (#17973) --- apps/desktop/sign.js | 62 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index 6a42666c46f..b8da98a882b 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -1,22 +1,60 @@ /* eslint-disable @typescript-eslint/no-require-imports, no-console */ +const child_process = require("child_process"); exports.default = async function (configuration) { - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") { + const ext = configuration.path.split(".").at(-1); + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ext == "exe") { console.log(`[*] Signing file: ${configuration.path}`); - require("child_process").execSync( - `azuresigntool sign -v ` + - `-kvu ${process.env.SIGNING_VAULT_URL} ` + - `-kvi ${process.env.SIGNING_CLIENT_ID} ` + - `-kvt ${process.env.SIGNING_TENANT_ID} ` + - `-kvs ${process.env.SIGNING_CLIENT_SECRET} ` + - `-kvc ${process.env.SIGNING_CERT_NAME} ` + - `-fd ${configuration.hash} ` + - `-du ${configuration.site} ` + - `-tr http://timestamp.digicert.com ` + - `"${configuration.path}"`, + child_process.execFileSync( + "azuresigntool", + // prettier-ignore + [ + "sign", + "-v", + "-kvu", process.env.SIGNING_VAULT_URL, + "-kvi", process.env.SIGNING_CLIENT_ID, + "-kvt", process.env.SIGNING_TENANT_ID, + "-kvs", process.env.SIGNING_CLIENT_SECRET, + "-kvc", process.env.SIGNING_CERT_NAME, + "-fd", configuration.hash, + "-du", configuration.site, + "-tr", "http://timestamp.digicert.com", + configuration.path, + ], { stdio: "inherit", }, ); + } else if (process.env.ELECTRON_BUILDER_SIGN_CERT && ["exe", "appx"].includes(ext)) { + console.log(`[*] Signing file: ${configuration.path}`); + if (process.platform !== "win32") { + console.warn( + "Signing Windows executables on non-Windows platforms is not supported. Not signing.", + ); + return; + } + const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT; + const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW; + if (!certPw) { + throw new Error( + "The certificate file password must be set in ELECTRON_BUILDER_SIGN_CERT_PW in order to sign files.", + ); + } + try { + child_process.execFileSync( + "signtool.exe", + ["sign", "/fd", "SHA256", "/a", "/f", certFile, "/p", certPw, configuration.path], + { + stdio: "inherit", + }, + ); + console.info(`Signed ${configuration.path} successfully.`); + } catch (error) { + throw new Error( + `Failed to sign ${configuration.path}: ${error.message}\n` + + `Check that ELECTRON_BUILDER_SIGN_CERT points to a valid PKCS12 file ` + + `and ELECTRON_BUILDER_SIGN_CERT_PW is correct.`, + ); + } } }; From 1714660bdec5289003ebb36786ac41f50b30466d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 08:19:13 +1000 Subject: [PATCH 079/101] [deps] AC: Update bufferutil to v4.1.0 (#18280) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index eec3487b6d4..32d5abebb91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", @@ -19321,9 +19321,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1cfddb16c42..7aba2035dce 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", From 404d925f845eed52991053438fa839eabaac9526 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:39:22 -0800 Subject: [PATCH 080/101] [PM-24560] - Add Archive UI Element to View and Edit Item Cards (#16954) * finalize new UI elements for archive/unarchive * add tests * add missing service * add tests * updates to edit and view pages * use structureClone * fix lint * fix typo * clean up return types * fixes to archive UI * fix tests * use @if and userId$ --- apps/browser/src/_locales/en/messages.json | 3 + .../add-edit/add-edit-v2.component.html | 38 +++- .../add-edit/add-edit-v2.component.spec.ts | 139 +++++++++++++- .../add-edit/add-edit-v2.component.ts | 68 ++++++- .../vault-v2/view-v2/view-v2.component.html | 70 ++++--- .../view-v2/view-v2.component.spec.ts | 171 ++++++++++++++++- .../vault-v2/view-v2/view-v2.component.ts | 26 +++ .../vault-item-dialog.component.html | 25 ++- .../vault-item-dialog.component.spec.ts | 174 +++++++++++++++++- .../vault-item-dialog.component.ts | 110 ++++++++--- apps/web/src/locales/en/messages.json | 3 + .../abstractions/cipher-archive.service.ts | 6 +- .../src/vault/models/view/cipher.view.ts | 4 + .../default-cipher-archive.service.spec.ts | 4 + .../default-cipher-archive.service.ts | 19 +- .../archive-cipher-utilities.service.spec.ts | 6 +- .../archive-cipher-utilities.service.ts | 80 ++++---- 17 files changed, 824 insertions(+), 122 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1613373bd62..d3a393ecc37 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 8f184c6a0c1..7230c565a48 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -31,14 +31,34 @@ {{ "cancel" | i18n }} - + + @if (isEditMode) { + @if ((archiveFlagEnabled$ | async) && isCipherArchived) { + + } + @if ((userCanArchive$ | async) && canCipherBeArchived) { + + } + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index f2c9d470816..4ffe44133d7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -45,15 +49,15 @@ describe("AddEditV2Component", () => { let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +65,10 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -83,10 +91,21 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,6 +113,11 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); fixture = TestBed.createComponent(AddEditV2Component); @@ -356,6 +380,111 @@ describe("AddEditV2Component", () => { }); }); + describe("archive", () => { + it("calls archiveCipherUtilsService service to archive the cipher", async () => { + buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await fixture.whenStable(); + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archiveCipherUtilsService service to unarchive the cipher", async () => { + buildConfigResponse.originalCipher = { + id: "222-333-444-5555", + archivedDate: new Date(), + edit: true, + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + ); + }); + }); + + describe("archive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher; + }); + + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { edit: true } as Cipher; + }); + + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 22aad854dd0..8704694fd53 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -31,6 +32,8 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -159,6 +162,7 @@ export type AddEditQueryParams = Partial>; ], }) export class AddEditV2Component implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -171,6 +175,18 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + + get canCipherBeArchived(): boolean { + return this.cipher?.canBeArchived; + } + + get isCipherArchived(): boolean { + return this.cipher?.isArchived; + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; @@ -182,6 +198,16 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.archiveService.userCanArchive$(account.id)), + ); + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +222,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -322,6 +350,10 @@ export class AddEditV2Component implements OnInit, OnDestroy { await BrowserApi.sendMessage("addEditCipherSubmitted"); } + get isEditMode(): boolean { + return ["edit", "partial-edit"].includes(this.config?.mode); + } + subscribeToParams(): void { this.route.queryParams .pipe( @@ -430,6 +462,40 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.i18nService.t(translation[type]); } + /** + * Update the cipher in the form after archiving/unarchiving. + * @param revisionDate The new revision date. + * @param archivedDate The new archived date (null if unarchived). + **/ + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipherFormComponent().patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + new Date(cipherResponse.archivedDate), + ); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + }; + delete = async () => { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 9b8380a4214..d2a4aaab3f0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -3,37 +3,47 @@ - + @if (cipher) { + + } - - - - - + @if (!cipher.isDeleted) { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 3d4fdb2e9f9..9c536a7e85a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,20 +15,32 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; @@ -62,7 +78,9 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: [], }, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +102,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,6 +117,10 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -142,6 +166,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -154,6 +233,7 @@ describe("ViewV2Component", () => { fixture = TestBed.createComponent(ViewV2Component); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +432,93 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1dea91c0b9f..64fa42bb2ba 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -42,6 +43,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -114,6 +116,10 @@ export class ViewV2Component { senderTabId?: number; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +137,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -272,6 +280,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 16256ab875a..c863608ba10 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -2,7 +2,8 @@ {{ title }} - @if (cipherIsArchived) { + + @if (isCipherArchived) { {{ "archived" | i18n }} } @@ -83,8 +84,28 @@ } - @if (showDelete) { + @if (showActionButtons) {
+ @if (userCanArchive$ | async) { + @if (isCipherArchived) { + + } + @if (cipher.canBeArchived) { + + } + } + + + + diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts new file mode 100644 index 00000000000..f1350cda49e --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts @@ -0,0 +1,182 @@ +import { CurrencyPipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { MAX_STORAGE_GB } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +type RemoveStorage = { + type: "remove"; + existing: number; +}; + +type AddStorage = { + type: "add"; + price: number; + provided: number; + cadence: SubscriptionCadence; + existing?: number; +}; + +export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage; + +type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted"; + +@Component({ + templateUrl: "./adjust-account-subscription-storage-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [AccountBillingClient], + imports: [ + AsyncActionsModule, + ButtonModule, + CurrencyPipe, + DialogModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class AdjustAccountSubscriptionStorageDialogComponent { + private readonly accountBillingClient = inject(AccountBillingClient); + private readonly dialogParams = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + + readonly action = computed<"add" | "remove">(() => this.dialogParams.type); + + readonly price = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.price; + } + }); + + readonly provided = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.provided; + } + }); + + readonly term = computed>(() => { + if (this.dialogParams.type === "add") { + switch (this.dialogParams.cadence) { + case "annually": + return this.i18nService.t("year"); + case "monthly": + return this.i18nService.t("month"); + } + } + }); + + readonly existing = computed>(() => this.dialogParams.existing); + + readonly content = computed<{ + title: string; + body: string; + label: string; + }>(() => { + const action = this.action(); + switch (action) { + case "add": + return { + title: this.i18nService.t("addStorage"), + body: this.i18nService.t("storageAddNote"), + label: this.i18nService.t("gbStorageAdd"), + }; + case "remove": + return { + title: this.i18nService.t("removeStorage"), + body: this.i18nService.t("whenYouRemoveStorage"), + label: this.i18nService.t("gbStorageRemove"), + }; + } + }); + + readonly maxPurchasable = computed>(() => { + const provided = this.provided(); + if (provided) { + return MAX_STORAGE_GB - provided; + } + }); + + readonly maxValidatorValue = computed(() => { + const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB; + const existing = this.existing(); + const action = this.action(); + + switch (action) { + case "add": { + return existing ? maxPurchasable - existing : maxPurchasable; + } + case "remove": { + return existing ? existing : 0; + } + } + }); + + formGroup = new FormGroup({ + amount: new FormControl(1, { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(1), + Validators.max(this.maxValidatorValue()), + ], + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (!this.formGroup.valid || !this.formGroup.value.amount) { + return; + } + + const action = this.action(); + const existing = this.existing(); + const amount = this.formGroup.value.amount; + + switch (action) { + case "add": { + await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0)); + break; + } + case "remove": { + await this.accountBillingClient.updateSubscriptionStorage(existing! - amount); + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("adjustedStorage", amount), + }); + + this.dialogRef.close("submitted"); + }; + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => + dialogService.open( + AdjustAccountSubscriptionStorageDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 81169d719b6..83440646b48 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => { describe("upgradeToPremium", () => { it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { // Arrange - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( mockTokenizedPaymentMethod, mockBillingAddress, ); @@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => { const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { type: NonTokenizablePaymentMethods.accountCredit, }; - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( accountCreditPaymentMethod, mockBillingAddress, ); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index ae18ab4c629..b8d5637e471 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -143,7 +143,7 @@ export class UpgradePaymentService { ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); - await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress); await this.refreshAndSync(); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 34362b4be3e..77ae3b31837 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -142,7 +142,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { if (!this.selectedPlan()) { return { passwordManager: { - seats: { name: "", cost: 0, quantity: 0 }, + seats: { translationKey: "", cost: 0, quantity: 0 }, }, cadence: "annually", estimatedTax: 0, @@ -152,7 +152,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return { passwordManager: { seats: { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + translationKey: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, quantity: 1, }, diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 2fc39218cf8..5034b21d03d 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -30,6 +30,7 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -256,8 +257,8 @@ export class UserSubscriptionComponent implements OnInit { return null; } return discount.amountOff - ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } - : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; + ? { type: DiscountTypes.AmountOff, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1ec92241671..716f5895e5a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12614,5 +12614,11 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" } -} \ No newline at end of file +} diff --git a/libs/common/src/billing/models/response/bitwarden-subscription.response.ts b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts new file mode 100644 index 00000000000..870c4de7e3a --- /dev/null +++ b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts @@ -0,0 +1,102 @@ +import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response"; +import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart } from "@bitwarden/pricing"; +import { + BitwardenSubscription, + Storage, + SubscriptionStatus, + SubscriptionStatuses, +} from "@bitwarden/subscription"; + +export class BitwardenSubscriptionResponse extends BaseResponse { + status: SubscriptionStatus; + cart: Cart; + storage: Storage; + cancelAt?: Date; + canceled?: Date; + nextCharge?: Date; + suspension?: Date; + gracePeriod?: number; + + constructor(response: any) { + super(response); + + const status = this.getResponseProperty("Status"); + if ( + status !== SubscriptionStatuses.Incomplete && + status !== SubscriptionStatuses.IncompleteExpired && + status !== SubscriptionStatuses.Trialing && + status !== SubscriptionStatuses.Active && + status !== SubscriptionStatuses.PastDue && + status !== SubscriptionStatuses.Canceled && + status !== SubscriptionStatuses.Unpaid + ) { + throw new Error(`Failed to parse invalid subscription status: ${status}`); + } + this.status = status; + + this.cart = new CartResponse(this.getResponseProperty("Cart")); + this.storage = new StorageResponse(this.getResponseProperty("Storage")); + + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new Date(suspension); + } + + const gracePeriod = this.getResponseProperty("GracePeriod"); + if (gracePeriod) { + this.gracePeriod = gracePeriod; + } + + const nextCharge = this.getResponseProperty("NextCharge"); + if (nextCharge) { + this.nextCharge = new Date(nextCharge); + } + + const cancelAt = this.getResponseProperty("CancelAt"); + if (cancelAt) { + this.cancelAt = new Date(cancelAt); + } + + const canceled = this.getResponseProperty("Canceled"); + if (canceled) { + this.canceled = new Date(canceled); + } + } + + toDomain = (): BitwardenSubscription => { + switch (this.status) { + case SubscriptionStatuses.Incomplete: + case SubscriptionStatuses.IncompleteExpired: + case SubscriptionStatuses.PastDue: + case SubscriptionStatuses.Unpaid: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + suspension: this.suspension!, + gracePeriod: this.gracePeriod!, + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + nextCharge: this.nextCharge!, + cancelAt: this.cancelAt, + }; + } + case SubscriptionStatuses.Canceled: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + canceled: this.canceled!, + }; + } + } + }; +} diff --git a/libs/common/src/billing/models/response/cart.response.ts b/libs/common/src/billing/models/response/cart.response.ts new file mode 100644 index 00000000000..c1a1d17521a --- /dev/null +++ b/libs/common/src/billing/models/response/cart.response.ts @@ -0,0 +1,97 @@ +import { + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart, CartItem, Discount } from "@bitwarden/pricing"; + +import { DiscountResponse } from "./discount.response"; + +export class CartItemResponse extends BaseResponse implements CartItem { + translationKey: string; + quantity: number; + cost: number; + discount?: Discount; + + constructor(response: any) { + super(response); + + this.translationKey = this.getResponseProperty("TranslationKey"); + this.quantity = this.getResponseProperty("Quantity"); + this.cost = this.getResponseProperty("Cost"); + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = discount; + } + } +} + +class PasswordManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalStorage?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalStorage = this.getResponseProperty("AdditionalStorage"); + if (additionalStorage) { + this.additionalStorage = new CartItemResponse(additionalStorage); + } + } +} + +class SecretsManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalServiceAccounts?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts"); + if (additionalServiceAccounts) { + this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts); + } + } +} + +export class CartResponse extends BaseResponse implements Cart { + passwordManager: { + seats: CartItem; + additionalStorage?: CartItem; + }; + secretsManager?: { + seats: CartItem; + additionalServiceAccounts?: CartItem; + }; + cadence: SubscriptionCadence; + discount?: Discount; + estimatedTax: number; + + constructor(response: any) { + super(response); + + this.passwordManager = new PasswordManagerCartItemResponse( + this.getResponseProperty("PasswordManager"), + ); + + const secretsManager = this.getResponseProperty("SecretsManager"); + if (secretsManager) { + this.secretsManager = new SecretsManagerCartItemResponse(secretsManager); + } + + const cadence = this.getResponseProperty("Cadence"); + if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) { + throw new Error(`Failed to parse invalid cadence: ${cadence}`); + } + this.cadence = cadence; + + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = new DiscountResponse(discount); + } + + this.estimatedTax = this.getResponseProperty("EstimatedTax"); + } +} diff --git a/libs/common/src/billing/models/response/discount.response.ts b/libs/common/src/billing/models/response/discount.response.ts new file mode 100644 index 00000000000..03460a10df8 --- /dev/null +++ b/libs/common/src/billing/models/response/discount.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing"; + +export class DiscountResponse extends BaseResponse implements Discount { + type: DiscountType; + value: number; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) { + throw new Error(`Failed to parse invalid discount type: ${type}`); + } + this.type = type; + this.value = this.getResponseProperty("Value"); + } +} diff --git a/libs/common/src/billing/models/response/storage.response.ts b/libs/common/src/billing/models/response/storage.response.ts new file mode 100644 index 00000000000..7e270ccc934 --- /dev/null +++ b/libs/common/src/billing/models/response/storage.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Storage } from "@bitwarden/subscription"; + +export class StorageResponse extends BaseResponse implements Storage { + available: number; + used: number; + readableUsed: string; + + constructor(response: any) { + super(response); + + this.available = this.getResponseProperty("Available"); + this.used = this.getResponseProperty("Used"); + this.readableUsed = this.getResponseProperty("ReadableUsed"); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 048b0147a6f..ab8fe5decd8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -31,6 +31,8 @@ export enum FeatureFlag { PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", PM23341_Milestone_2 = "pm-23341-milestone-2", + PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page", + PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -137,6 +139,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, [FeatureFlag.PM23341_Milestone_2]: FALSE, + [FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE, + [FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 10f349fbec7..8839ea8ca50 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page postReinstatePremium(): Promise { return this.send("POST", "/accounts/reinstate-premium", null, true, false); } diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e2fe7d80dc0..e916de3995d 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -46,7 +46,7 @@
@let passwordManagerSeats = cart.passwordManager.seats;
- {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x + {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }} @@ -63,7 +63,7 @@
- {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x + {{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x {{ additionalStorage.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -86,7 +86,7 @@
- {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x + {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -105,7 +105,7 @@
{{ additionalServiceAccounts.quantity }} - {{ additionalServiceAccounts.name | i18n }} x + {{ additionalServiceAccounts.translationKey | i18n }} x {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} / {{ term }} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 02e705276bc..d327d5658fe 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures: ```typescript export type CartItem = { - name: string; // Display name for i18n lookup + translationKey: string; // Translation key for i18n lookup quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount @@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; export type Discount = { type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - active: boolean; // Whether discount is currently applied value: number; // Dollar amount or percentage (20 for 20%) }; ``` @@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, @@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 8.00 @@ -188,7 +186,7 @@ Show cart with yearly subscription: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 500.00 } }, @@ -211,12 +209,12 @@ Show cart with password manager and additional storage: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, @@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -304,24 +302,24 @@ Show a cart with all available products: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -344,19 +342,18 @@ Show cart with percentage-based discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 10.40 @@ -377,21 +374,20 @@ Show cart with fixed amount discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, cadence: 'annually', discount: { type: 'amount-off', - active: true, value: 50.00 }, estimatedTax: 95.00 @@ -431,7 +427,7 @@ Show cart with premium plan: passwordManager: { seats: { quantity: 1, - name: 'premiumMembership', + translationKey: 'premiumMembership', cost: 10.00 } }, @@ -454,7 +450,7 @@ Show cart with families plan: passwordManager: { seats: { quantity: 1, - name: 'familiesMembership', + translationKey: 'familiesMembership', cost: 40.00 } }, @@ -488,8 +484,7 @@ Show cart with families plan: - Use consistent naming and formatting for cart items - Include clear quantity and unit pricing information - Ensure tax estimates are accurate and clearly labeled -- Set `active: true` on discounts that should be displayed -- Use localized strings for CartItem names (for i18n lookup) +- Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index f019322e4db..10975585899 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, }; @@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => { expect(discountAmount.nativeElement.textContent).toContain("-$50.00"); }); - it("should not display discount when discount is inactive", () => { - // Arrange - const cartWithInactiveDiscount: Cart = { - ...mockCart, - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - }, - }; - fixture.componentRef.setInput("cart", cartWithInactiveDiscount); - fixture.detectChanges(); - - // Act / Assert - const discountSection = fixture.debugElement.query( - By.css('[data-testid="discount-section"]'), - ); - expect(discountSection).toBeFalsy(); - }); - it("should apply discount to total calculation", () => { // Arrange const cartWithDiscount: Cart = { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index aed23c54a30..581e363ab24 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -71,7 +71,7 @@ export default { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, @@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, @@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 500.0, }, }, @@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, @@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -190,24 +190,24 @@ export const AllProducts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -223,7 +223,7 @@ export const FamiliesPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "familiesMembership", + translationKey: "familiesMembership", cost: 40.0, }, }, @@ -239,7 +239,7 @@ export const PremiumPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, cadence: "monthly", discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, estimatedTax: 10.4, @@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, cadence: "annually", discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, estimatedTax: 95.0, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b92a465169c..ef35f0ded33 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -116,7 +116,7 @@ export class CartSummaryComponent { */ readonly discountAmount = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return 0; } @@ -136,7 +136,7 @@ export class CartSummaryComponent { */ readonly discountLabel = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return ""; } return getLabel(this.i18nService, discount); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx index f9b9ba85619..8988f79ea07 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; type Discount = { /** The type of discount */ type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - /** Whether the discount is currently active */ - active: boolean; /** The discount value (percentage or amount depending on type) */ value: number; }; @@ -47,8 +45,7 @@ type Discount = { ## Behavior -- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is - greater than 0. +- The badge is only displayed when `discount` is provided and `value` is greater than 0. - For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). - For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places. @@ -62,7 +59,3 @@ type Discount = { ### Amount Discount - -### Inactive Discount - - diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts index 6f8e7ab9e74..540ae48adb4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => { expect(component.display()).toBe(false); }); - it("should return false when discount is inactive", () => { + it("should return true when discount has percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: false, - value: 20, - }); - fixture.detectChanges(); - expect(component.display()).toBe(false); - }); - - it("should return true when discount is active with percent-off", () => { - fixture.componentRef.setInput("discount", { - type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); expect(component.display()).toBe(true); }); - it("should return true when discount is active with amount-off", () => { + it("should return true when discount has amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); @@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (percent-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (amount-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => { it("should return percentage text when type is percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); @@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => { it("should convert decimal value to percentage for percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0.15, }); fixture.detectChanges(); @@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => { it("should return amount text when type is amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts index 1d2d15e84c5..610e7b815a8 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -40,7 +40,6 @@ export const PercentDiscount: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, } as Discount, }, @@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 0.15, // 15% in decimal format } as Discount, }, @@ -68,7 +66,6 @@ export const AmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 10.99, } as Discount, }, @@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 99.99, } as Discount, }, }; -export const InactiveDiscount: Story = { - render: (args) => ({ - props: args, - template: ``, - }), - args: { - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - } as Discount, - }, -}; - export const NoDiscount: Story = { render: (args) => ({ props: args, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts index 17204be85ff..8937ea274d4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -23,7 +23,7 @@ export class DiscountBadgeComponent { if (!discount) { return false; } - return discount.active && discount.value > 0; + return discount.value > 0; }); readonly label = computed>(() => { diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index d27a867b785..ed5108edee8 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,7 +1,7 @@ import { Discount } from "@bitwarden/pricing"; export type CartItem = { - name: string; + translationKey: string; quantity: number; cost: number; discount?: Discount; diff --git a/libs/pricing/src/types/discount.ts b/libs/pricing/src/types/discount.ts index c12998ef609..afea56fce0a 100644 --- a/libs/pricing/src/types/discount.ts +++ b/libs/pricing/src/types/discount.ts @@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes]; export type Discount = { type: DiscountType; - active: boolean; value: number; }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html index 851ae32ddb3..c4d3d291b26 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html @@ -13,8 +13,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('download-license')" + [disabled]="downloadLicenseDisabled()" + (click)="callToActionClicked.emit(actions.DownloadLicense)" > {{ "downloadLicense" | i18n }} @@ -22,8 +22,8 @@ bitButton buttonType="danger" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('cancel-subscription')" + [disabled]="cancelSubscriptionDisabled()" + (click)="callToActionClicked.emit(actions.CancelSubscription)" > {{ "cancelSubscription" | i18n }} diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx index 4519d19a530..3162e740cb0 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -21,6 +21,8 @@ subscription actions. - [Examples](#examples) - [Default](#default) - [Actions Disabled](#actions-disabled) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | ### Outputs @@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations): ```html ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -downloading the license or processing subscription cancellation. +**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + + + +```html + + +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + + + +```html + + +``` ## Features @@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation. - Handle both `download-license` and `cancel-subscription` events in parent components - Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) -- Disable buttons or show loading states during async operations +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations - Provide clear user feedback after action completion - Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic ### ❌ Don't diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts index 345de037fd3..3346c287beb 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => { }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { - fixture.componentRef.setInput("callsToActionDisabled", true); + describe("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false", () => { - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); - expect(buttons[1].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons by default", () => { + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); - expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); expect(buttons[1].nativeElement.disabled).toBe(false); }); }); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts index 66c151f536f..7dd7a5375fe 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -44,6 +44,23 @@ export const Default: Story = { export const ActionsDisabled: Story = { name: "Actions Disabled", args: { - callsToActionDisabled: true, + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, }, }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts index a962a167ec6..6c633a43d93 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; @Component({ selector: "billing-additional-options-card", @@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], }) export class AdditionalOptionsCardComponent { - readonly callsToActionDisabled = input(false); + readonly downloadLicenseDisabled = input(false); + readonly cancelSubscriptionDisabled = input(false); + readonly callToActionClicked = output(); + + protected readonly actions = AdditionalOptionsCardActions; } diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html index c11f1917176..f8ac4b18604 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.html +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -21,8 +21,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('add-storage')" + [disabled]="addStorageDisabled()" + (click)="callToActionClicked.emit(actions.AddStorage)" > {{ "addStorage" | i18n }} @@ -30,8 +30,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled() || !canRemoveStorage()" - (click)="callToActionClicked.emit('remove-storage')" + [disabled]="removeStorageDisabled()" + (click)="callToActionClicked.emit(actions.RemoveStorage)" > {{ "removeStorage" | i18n }} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx index 43215cb863c..7e06fa23553 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.mdx +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -30,6 +30,8 @@ full). - [Large Storage Pool (1TB)](#large-storage-pool-1tb) - [Small Storage Pool (1GB)](#small-storage-pool-1gb) - [Actions Disabled](#actions-disabled) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------------------ | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | ### Outputs @@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage: Key behaviors: - Progress bar color changes from blue (primary) to red (danger) when full -- Remove storage button is disabled when storage is full +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs - Title changes to "Storage full" when at capacity - Description provides context-specific messaging @@ -123,7 +127,7 @@ Storage with no files uploaded: [storage]="{ available: 5, used: 0, - readableUsed: '0 GB' + readableUsed: '0 GB', }" (callToActionClicked)="handleAction($event)" > @@ -141,7 +145,7 @@ Storage with partial usage (50%): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" (callToActionClicked)="handleAction($event)" > @@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button: [storage]="{ available: 5, used: 5, - readableUsed: '5 GB' + readableUsed: '5 GB', }" (callToActionClicked)="handleAction($event)" > ``` -**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns -red. +**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. ### Low Usage (10%) @@ -180,7 +184,7 @@ Minimal storage usage: [storage]="{ available: 5, used: 0.5, - readableUsed: '500 MB' + readableUsed: '500 MB', }" (callToActionClicked)="handleAction($event)" > @@ -198,7 +202,7 @@ Substantial storage usage: [storage]="{ available: 5, used: 3.75, - readableUsed: '3.75 GB' + readableUsed: '3.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -216,7 +220,7 @@ Storage approaching capacity: [storage]="{ available: 5, used: 4.75, - readableUsed: '4.75 GB' + readableUsed: '4.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -234,7 +238,7 @@ Enterprise-level storage allocation: [storage]="{ available: 1000, used: 734, - readableUsed: '734 GB' + readableUsed: '734 GB', }" (callToActionClicked)="handleAction($event)" > @@ -252,7 +256,7 @@ Minimal storage allocation: [storage]="{ available: 1, used: 0.8, - readableUsed: '800 MB' + readableUsed: '800 MB', }" (callToActionClicked)="handleAction($event)" > @@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" - [callsToActionDisabled]="true" + [addStorageDisabled]="true" + [removeStorageDisabled]="true" (callToActionClicked)="handleAction($event)" > ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -adding or removing storage. +**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + + + +```html + + +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + + + +```html + + +``` ## Features @@ -304,13 +349,14 @@ adding or removing storage. - Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` - Keep `used` value less than or equal to `available` under normal circumstances - Update storage data in real-time when user adds or removes storage -- Disable UI interactions when storage operations are in progress +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations - Show loading states during async storage operations +- Control button states independently based on business logic ### ❌ Don't -- Omit the `readableUsed` field - it's required for display -- Use inconsistent units between `available` and `used` (both should be in GB) +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) - Allow negative values for storage amounts - Ignore the `callToActionClicked` events - they require handling - Display inaccurate or stale storage information diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts index ae0d7ad9dcb..fe2223f1449 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -163,18 +163,6 @@ describe("StorageCardComponent", () => { }); }); - describe("canRemoveStorage", () => { - it("should return true when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - expect(component.canRemoveStorage()).toBe(true); - }); - - it("should return false when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - expect(component.canRemoveStorage()).toBe(false); - }); - }); - describe("button rendering", () => { it("should render both buttons", () => { setupComponent(baseStorage); @@ -182,25 +170,46 @@ describe("StorageCardComponent", () => { expect(buttons.length).toBe(2); }); - it("should enable remove button when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + it("should enable add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1].nativeElement; expect(removeButton.disabled).toBe(false); }); - it("should disable remove button when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1]; expect(removeButton.attributes["aria-disabled"]).toBe("true"); }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { setupComponent(baseStorage); - fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -208,9 +217,10 @@ describe("StorageCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should enable both buttons when both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -218,15 +228,27 @@ describe("StorageCardComponent", () => { expect(buttons[1].nativeElement.disabled).toBe(false); }); - it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); }); describe("button click events", () => { @@ -243,7 +265,7 @@ describe("StorageCardComponent", () => { }); it("should emit remove-storage action when remove button is clicked", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + setupComponent(baseStorage); const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts index 8c2070e59f9..2afbaf0d0b1 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -143,6 +143,33 @@ export const ActionsDisabled: Story = { used: 2.5, readableUsed: "2.5 GB", } satisfies Storage, - callsToActionDisabled: true, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, }, }; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts index 988f4a0ec60..483649434ff 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { Storage } from "../../types/storage"; -export type StorageCardAction = "add-storage" | "remove-storage"; +export const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; @Component({ selector: "billing-storage-card", @@ -25,7 +30,8 @@ export class StorageCardComponent { readonly storage = input.required(); - readonly callsToActionDisabled = input(false); + readonly addStorageDisabled = input(false); + readonly removeStorageDisabled = input(false); readonly callToActionClicked = output(); @@ -64,5 +70,5 @@ export class StorageCardComponent { return this.isFull() ? "danger" : "primary"; }); - readonly canRemoveStorage = computed(() => !this.isFull()); + protected readonly actions = StorageCardActions; } diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index 0f605f0f05e..c9cc6df7263 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub ### Outputs -| Output | Type | Description | -| --------------------- | ---------------- | ---------------------------------------------------------- | -| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | +| Output | Type | Description | +| --------------------- | ------------------------ | ---------------------------------------------------------- | +| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | -**PlanCardAction Type:** +**SubscriptionCardAction Type:** ```typescript -type PlanCardAction = +type SubscriptionCardAction = | "contact-support" | "manage-invoices" | "reinstate-subscription" diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index 3485f2a493a..cdb85360c74 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index abe5789382b..32976c89cc2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -103,7 +103,7 @@ export const Active: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -157,7 +157,7 @@ export const Trial: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -212,7 +212,7 @@ export const Incomplete: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -239,7 +239,7 @@ export const IncompleteExpired: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -266,7 +266,7 @@ export const PastDue: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -293,7 +293,7 @@ export const PendingCancellation: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -320,7 +320,7 @@ export const Unpaid: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -346,7 +346,7 @@ export const Canceled: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -372,31 +372,30 @@ export const Enterprise: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 7, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 0.5, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 13, }, additionalServiceAccounts: { quantity: 5, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 1, }, }, discount: { type: DiscountTypes.PercentOff, - active: true, - value: 0.25, + value: 25, }, cadence: "monthly", estimatedTax: 6.4, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index f52127a0104..ebfb41df6c2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; import { I18nPipe } from "@bitwarden/ui-common"; -export type PlanCardAction = - | "contact-support" - | "manage-invoices" - | "reinstate-subscription" - | "update-payment" - | "upgrade-plan"; +export const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; type Badge = { text: string; variant: BadgeVariant }; @@ -33,7 +37,7 @@ type Callout = Maybe<{ callsToAction?: { text: string; buttonType: ButtonType; - action: PlanCardAction; + action: SubscriptionCardAction; }[]; }>; @@ -64,7 +68,7 @@ export class SubscriptionCardComponent { readonly showUpgradeButton = input(false); - readonly callToActionClicked = output(); + readonly callToActionClicked = output(); readonly badge = computed(() => { const subscription = this.subscription(); @@ -136,12 +140,12 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("updatePayment"), buttonType: "unstyled", - action: "update-payment", + action: SubscriptionCardActions.UpdatePayment, }, { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -155,7 +159,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -172,7 +176,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("reinstateSubscription"), buttonType: "unstyled", - action: "reinstate-subscription", + action: SubscriptionCardActions.ReinstateSubscription, }, ], }; @@ -189,7 +193,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("upgradeNow"), buttonType: "unstyled", - action: "upgrade-plan", + action: SubscriptionCardActions.UpgradePlan, }, ], }; @@ -208,7 +212,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; @@ -225,7 +229,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts index 15bf64d03aa..5c43ed20590 100644 --- a/libs/subscription/src/types/bitwarden-subscription.ts +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -12,6 +12,8 @@ export const SubscriptionStatuses = { Unpaid: "unpaid", } as const; +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + type HasCart = { cart: Cart; }; diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts index beb187250dd..35df54cb4f2 100644 --- a/libs/subscription/src/types/storage.ts +++ b/libs/subscription/src/types/storage.ts @@ -1,3 +1,5 @@ +export const MAX_STORAGE_GB = 100; + export type Storage = { available: number; readableUsed: string; From 1ac6d3b2aa960ceb48e2b3a0f0305979ba71f5d0 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:45:35 -0600 Subject: [PATCH 094/101] add missing translation keys (#18232) --- apps/desktop/src/locales/en/messages.json | 6 ++++++ apps/web/src/locales/en/messages.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b00233457ec..33de901c06b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4010,6 +4010,12 @@ }, "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" }, "missingWebsite": { "message": "Missing website" diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 716f5895e5a..8adfaac88f2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11619,6 +11619,12 @@ }, "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" }, "missingWebsite": { "message": "Missing website" From 0aac4cae3567abf8c0400507f08c74ce6e36f763 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:05:20 -0800 Subject: [PATCH 095/101] allow deleting of failed decrypted cipher (#18279) --- .../item-more-options.component.html | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index b86ec24fd20..04b59d0ee0e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -4,61 +4,62 @@ bitIconButton="bwi-ellipsis-v" size="small" [label]="'moreOptionsLabel' | i18n: cipher.name" - [disabled]="decryptionFailure" [bitMenuTriggerFor]="moreOptions" > - - - + + + + - - - - - - @if (canEdit) { - - } - - - {{ "clone" | i18n }} - - - {{ "assignToCollections" | i18n }} - - - @if (showArchive$ | async) { - @if (canArchive$ | async) { - - } @else { - + } @else { +
- + + } } } @if (canDelete$ | async) { From 27d43c500f2efbe32e2c68b7a0485f70d24ac544 Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Mon, 12 Jan 2026 13:26:50 -0500 Subject: [PATCH 096/101] PM-28183 implemented new sends filter and search design (#17901) * PM-28183 implemented new sends filter and search design * PM-28183 resolved table issue fallout from merge conflict * PM-28183 resolved browser paste url issue * PM-28183 put new feature behind feature flag * PM-28183 resolved feature flag * PM-28183 resolved type-safe approach pr comment * PM-28183 resolved DesktopSendUIRefresh feature flag is enabled. pr comment * PM-28183 restored SendUIRefresh * PM-28183 resolved query parameter subscription pr comment * PM-28183 resolved pr comment re enum like objects * PM-28183 resolved remove enum like objects pr comment * PM-28183 resolved pr comment re defining filteredSends member variable * PM-28183 resolved pr comment re Code Duplication in syncCompleted Handler * PM-28183 resolved pr comment re Floating Promise * PM-28183 restored feature flag * PM-28183 resolved pr comment re Dual Binding Pattern * PM28183 resolved options cell button pr comment * PM 28183 resolved pr comment re Incorrect CSS Class - Breaking Layout * PM 28183 resolved pr comment re uery Param Update Causes Redundant Filter Application * PM-28183 resolved lint issues * PM 28183 resolved lint issues * PM-28183 resolved type issue with import * PM-28183 resolved import in failling test * chore: rerun web build * PM-28183 resolved build issues * PM-28183 resolved build issues * PM-28183 resolved lint issues --- .../add-edit/send-add-edit.component.ts | 2 +- .../send-created.component.spec.ts | 2 +- ...-file-popout-dialog-container.component.ts | 2 +- .../popup/send-v2/send-v2.component.spec.ts | 2 +- .../tools/popup/send-v2/send-v2.component.ts | 4 +- .../src/tools/send/commands/create.command.ts | 2 +- .../src/tools/send/commands/edit.command.ts | 2 +- .../tools/send/commands/receive.command.ts | 2 +- .../tools/send/commands/template.command.ts | 2 +- .../tools/send/models/send-access.response.ts | 2 +- .../src/tools/send/models/send.response.ts | 2 +- apps/cli/src/tools/send/send.program.ts | 2 +- .../send-filters-nav.component.spec.ts | 2 +- .../send-v2/send-filters-nav.component.ts | 2 +- .../tools/send-v2/send-v2.component.spec.ts | 2 +- .../app/tools/send-v2/send-v2.component.ts | 2 +- .../new-send-dropdown.component.spec.ts | 2 +- .../new-send/new-send-dropdown.component.ts | 2 +- .../send/send-access/send-view.component.ts | 2 +- .../src/app/tools/send/send.component.html | 247 +++++++++++------- apps/web/src/app/tools/send/send.component.ts | 64 ++++- .../send-success-drawer-dialog.component.ts | 2 +- .../src/tools/send/add-edit.component.ts | 2 +- libs/angular/src/tools/send/send.component.ts | 4 +- .../src/tools/send/models/data/send.data.ts | 2 +- .../send/models/domain/send-access.spec.ts | 2 +- .../tools/send/models/domain/send-access.ts | 2 +- .../src/tools/send/models/domain/send.spec.ts | 2 +- .../src/tools/send/models/domain/send.ts | 2 +- .../tools/send/models/request/send.request.ts | 2 +- .../models/response/send-access.response.ts | 2 +- .../send/models/response/send.response.ts | 2 +- .../send/models/view/send-access.view.ts | 2 +- .../src/tools/send/models/view/send.view.ts | 2 +- .../tools/send/services/send-api.service.ts | 2 +- .../tools/send/services/send.service.spec.ts | 2 +- .../src/tools/send/services/send.service.ts | 2 +- .../services/test-data/send-tests.data.ts | 2 +- .../src/tools/send/types/send-filter-type.ts | 7 + .../tools/send/{enums => types}/send-type.ts | 0 .../send-add-edit-dialog.component.ts | 2 +- .../new-send-dropdown-v2.component.spec.ts | 2 +- .../new-send-dropdown-v2.component.ts | 2 +- .../new-send-dropdown.component.ts | 2 +- .../abstractions/send-form-config.service.ts | 2 +- .../options/send-options.component.spec.ts | 2 +- .../send-details/send-details.component.ts | 2 +- .../send-file-details.component.ts | 2 +- .../components/send-form.component.ts | 2 +- .../default-send-form-config.service.ts | 2 +- .../send-list-items-container.component.ts | 2 +- .../send-table.component.stories.ts | 2 +- .../src/send-table/send-table.component.ts | 2 +- .../send-list-filters.service.spec.ts | 2 +- .../src/services/send-list-filters.service.ts | 2 +- 55 files changed, 276 insertions(+), 148 deletions(-) create mode 100644 libs/common/src/tools/send/types/send-filter-type.ts rename libs/common/src/tools/send/{enums => types}/send-type.ts (100%) diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 8f30d00cc31..f180564b912 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -9,9 +9,9 @@ import { map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 1a3df238543..521d72bba0c 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,9 +11,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 1f0d9f2a0c9..ddf50eb39bf 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { SendFormConfig } from "@bitwarden/send-ui"; diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6e73d9811f2..dfbfabf8d5e 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -17,10 +17,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 89769bdd1ce..f36a475a805 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -13,7 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; @@ -139,7 +139,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; + this.title = this.sendTypeTitles[currentFilter.sendType as SendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 7803f6f94d4..91e579c26c1 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -9,9 +9,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; import { Response } from "../../../models/response"; diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index bf53c8a5cb9..2c6d41d66ac 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -5,9 +5,9 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index a412f7c1667..5cbf458c87f 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -13,11 +13,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts index c1c2c97b03d..09213ac5fa8 100644 --- a/apps/cli/src/tools/send/commands/template.command.ts +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -1,4 +1,4 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { TemplateResponse } from "../../../models/response/template.response"; diff --git a/apps/cli/src/tools/send/models/send-access.response.ts b/apps/cli/src/tools/send/models/send-access.response.ts index 07877bfb548..7bd54801307 100644 --- a/apps/cli/src/tools/send/models/send-access.response.ts +++ b/apps/cli/src/tools/send/models/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a0c1d3f83c6..b7655226be0 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 33bf4518ccd..869d77a379c 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -7,7 +7,7 @@ import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseProgram } from "../../base-program"; import { Response } from "../../models/response"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index ab881e5b57b..f22b94974d1 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { GlobalStateProvider } from "@bitwarden/state"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts index 28004f475e5..0dfdc1ee7c5 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -4,7 +4,7 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import { filter, map, startWith } from "rxjs"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 713915e3cf7..8a6e22cc402 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -16,10 +16,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 6a44713d309..be49e6593e4 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -22,9 +22,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, DialogService, ToastService } from "@bitwarden/components"; import { diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts index 134eaac2956..e9ef85867e7 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts @@ -8,9 +8,9 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SendAddEditDialogComponent } from "@bitwarden/send-ui"; diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index dca70dca4b8..68c8c188d31 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -8,7 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; import { DefaultSendFormConfigService, diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 0397575f021..060dc1958b1 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -11,12 +11,12 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 6418744a727..8a6f720bb45 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -17,100 +17,159 @@ {{ "sendDisabledWarning" | i18n }} -
-
-
-
- {{ "filters" | i18n }} -
-
-
- -
-
-
    -
  • - - - -
  • -
-
-
-
-

{{ "types" | i18n }}

-
-
    -
  • - - - -
  • -
  • - - - -
  • -
-
-
-
-
-
- -
- - - {{ "loading" | i18n }} - - - - {{ "sendsTitleNoItems" | i18n }} - {{ "sendsBodyNoItems" | i18n }} - - - +@if (SendUIRefresh$ | async) { +
+ +
+ +
+ + {{ "allSends" | i18n }} + {{ "sendTypeText" | i18n }} + {{ "sendTypeFile" | i18n }} + +
+ +
+
+ + +
+ + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
-
+} @else { +
+
+
+
+ {{ "filters" | i18n }} +
+
+
+ +
+
+
    +
  • + + + +
  • +
+
+
+
+

{{ "types" | i18n }}

+
+
    +
  • + + + +
  • +
  • + + + +
  • +
+
+
+
+
+
+ + +
+ + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
+
+
+} diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index eb3d92ebe26..db45b104900 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -1,7 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, NgZone, OnInit, OnDestroy } from "@angular/core"; -import { lastValueFrom } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { lastValueFrom, Observable, switchMap, EMPTY } from "rxjs"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; import { NoSendsIcon } from "@bitwarden/assets/svg"; @@ -17,6 +19,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendFilterType } from "@bitwarden/common/tools/send/types/send-filter-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { @@ -26,6 +30,7 @@ import { SearchModule, TableDataSource, ToastService, + ToggleGroupModule, } from "@bitwarden/components"; import { DefaultSendFormConfigService, @@ -53,6 +58,7 @@ const BroadcasterSubscriptionId = "SendComponent"; NoItemsModule, HeaderModule, NewSendDropdownComponent, + ToggleGroupModule, SendTableComponent, ], templateUrl: "send.component.html", @@ -61,6 +67,8 @@ const BroadcasterSubscriptionId = "SendComponent"; export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { private sendItemDialogRef?: DialogRef | undefined; noItemIcon = NoSendsIcon; + selectedToggleValue?: SendFilterType; + SendUIRefresh$: Observable; override set filteredSends(filteredSends: SendView[]) { super.filteredSends = filteredSends; @@ -88,6 +96,8 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService: ToastService, private addEditFormConfigService: DefaultSendFormConfigService, accountService: AccountService, + private route: ActivatedRoute, + private router: Router, private configService: ConfigService, ) { super( @@ -104,10 +114,38 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService, accountService, ); + + this.SendUIRefresh$ = this.configService.getFeatureFlag$(FeatureFlag.SendUIRefresh); + + this.SendUIRefresh$.pipe( + switchMap((sendUiRefreshEnabled) => { + if (sendUiRefreshEnabled) { + return this.route.queryParamMap; + } + return EMPTY; + }), + takeUntilDestroyed(), + ).subscribe((params) => { + const typeParam = params.get("type"); + const value = ( + typeParam === SendFilterType.Text || typeParam === SendFilterType.File + ? typeParam + : SendFilterType.All + ) as SendFilterType; + this.selectedToggleValue = value; + + if (this.loaded) { + this.applyTypeFilter(value); + } + }); } async ngOnInit() { await super.ngOnInit(); + this.onSuccessfulLoad = async () => { + this.applyTypeFilter(this.selectedToggleValue); + }; + await this.load(); // Broadcaster subscription - load if sync completes in the background @@ -194,4 +232,28 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro }); } } + + private applyTypeFilter(value: SendFilterType) { + if (value === SendFilterType.All) { + this.selectAll(); + } else if (value === SendFilterType.Text) { + this.selectType(SendType.Text); + } else if (value === SendFilterType.File) { + this.selectType(SendType.File); + } + } + + onToggleChange(value: SendFilterType) { + const queryParams = value === SendFilterType.All ? { type: null } : { type: value }; + + this.router + .navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: "merge", + }) + .catch((err) => { + this.logService.error("Failed to update route query params:", err); + }); + } } diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts index 1cea9b83428..67e01cd9ff0 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -5,8 +5,8 @@ import { ActiveSendIcon } from "@bitwarden/assets/svg"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index e03162c2d91..e466605b43c 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -27,13 +27,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index e96bdd8e31a..26d4493c8fd 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -20,10 +20,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -78,7 +78,7 @@ export class SendComponent implements OnInit, OnDestroy { protected ngZone: NgZone, protected searchService: SearchService, protected policyService: PolicyService, - private logService: LogService, + protected logService: LogService, protected sendApiService: SendApiService, protected dialogService: DialogService, protected toastService: ToastService, diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 2c6377de0c9..bfa72b04087 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; import { SendFileData } from "./send-file.data"; diff --git a/libs/common/src/tools/send/models/domain/send-access.spec.ts b/libs/common/src/tools/send/models/domain/send-access.spec.ts index 686236bff8e..58083d8a4bb 100644 --- a/libs/common/src/tools/send/models/domain/send-access.spec.ts +++ b/libs/common/src/tools/send/models/domain/send-access.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { mockContainerService, mockEnc } from "../../../../../spec"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccess } from "./send-access"; diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index 68d1af7b57e..1877a5c1148 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -3,7 +3,7 @@ import { EncString } from "../../../../key-management/crypto/models/enc-string"; import Domain from "../../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccessView } from "../view/send-access.view"; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index dc9ca7d3444..b0cfd200483 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -11,7 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { Send } from "./send"; diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 2bf16de8a44..b85509183b0 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,7 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index f7e3ff26d7f..902ca0a2c54 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; import { Send } from "../domain/send"; diff --git a/libs/common/src/tools/send/models/response/send-access.response.ts b/libs/common/src/tools/send/models/response/send-access.response.ts index 65a98e527a4..54107017fcf 100644 --- a/libs/common/src/tools/send/models/response/send-access.response.ts +++ b/libs/common/src/tools/send/models/response/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 5c6bd4dc1a6..6bbaf91ebe8 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/view/send-access.view.ts b/libs/common/src/tools/send/models/view/send-access.view.ts index cb8b29796af..9d1b56d88ec 100644 --- a/libs/common/src/tools/send/models/view/send-access.view.ts +++ b/libs/common/src/tools/send/models/view/send-access.view.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { View } from "../../../../models/view/view"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccess } from "../domain/send-access"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 54657b12438..1bb3b527a73 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,7 +4,7 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { Send } from "../domain/send"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index f709553646f..1c931b7ad98 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -6,7 +6,6 @@ import { FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; @@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response"; import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; +import { SendType } from "../types/send-type"; import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; import { InternalSendService } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 397ae905e31..fb99ddbe3bc 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service"; import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; import { SendView } from "../models/view/send.view"; +import { SendType } from "../types/send-type"; import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; import { SendStateProvider } from "./send-state.provider"; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 810dbc05a2f..c274d90146e 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendFile } from "../models/domain/send-file"; @@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text"; import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendType } from "../types/send-type"; import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index 784d54bd71f..c1d04ab2926 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -1,12 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { EncString } from "../../../../key-management/crypto/models/enc-string"; -import { SendType } from "../../enums/send-type"; import { SendTextApi } from "../../models/api/send-text.api"; import { SendTextData } from "../../models/data/send-text.data"; import { SendData } from "../../models/data/send.data"; import { Send } from "../../models/domain/send"; import { SendView } from "../../models/view/send.view"; +import { SendType } from "../../types/send-type"; export function testSendViewData(id: string, name: string) { const data = new SendView({} as any); diff --git a/libs/common/src/tools/send/types/send-filter-type.ts b/libs/common/src/tools/send/types/send-filter-type.ts new file mode 100644 index 00000000000..dd26536076a --- /dev/null +++ b/libs/common/src/tools/send/types/send-filter-type.ts @@ -0,0 +1,7 @@ +export const SendFilterType = Object.freeze({ + All: "all", + Text: "text", + File: "file", +} as const); + +export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType]; diff --git a/libs/common/src/tools/send/enums/send-type.ts b/libs/common/src/tools/send/types/send-type.ts similarity index 100% rename from libs/common/src/tools/send/enums/send-type.ts rename to libs/common/src/tools/send/types/send-type.ts diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index d2f2c2204b9..15b50a3809c 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogRef, diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts index 8f8390a170c..acdb7b56c2b 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts index 7e7c4a2005b..f586373de70 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts @@ -6,7 +6,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index e1474175267..b5cbeced209 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 0859986664a..4f30860b6a6 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -1,5 +1,5 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index 6724bb324c3..fa069b92ed2 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -6,9 +6,9 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DialogService, ToastService } from "@bitwarden/components"; import { CredentialGeneratorService } from "@bitwarden/generator-core"; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index ec351bee923..e2b50eafc99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, SectionHeaderComponent, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 4e4900039c7..7b00f17cc9c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, FormFieldModule, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 0471ed90eef..53a9365bf99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -18,8 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AsyncActionsModule, BitSubmitDirective, diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index 343fa880795..9178991a028 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index d885f279bc6..63f4b97105a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts index d2d630b69a2..3a5e3239692 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts @@ -1,8 +1,8 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { TableDataSource, I18nMockService } from "@bitwarden/components"; import { SendTableComponent } from "./send-table.component"; diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index c912a01f98a..e46f59bab17 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -2,8 +2,8 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index ef38938aba8..096ae95ad66 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendListFiltersService } from "./send-list-filters.service"; diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index b266ad08a69..cf84204ba0d 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms"; import { map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ChipSelectOption } from "@bitwarden/components"; From 314a5baada1025b3039d1b26658867cbbebc751f Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Mon, 12 Jan 2026 13:19:46 -0600 Subject: [PATCH 097/101] Sign Appx in CI (#17975) Changes the publisher to match the Bitwarden signing certificate, and allows signing of .appx files. Also removes unused certificateSubjectName parameters from package.json --- apps/desktop/electron-builder.beta.json | 4 +++- apps/desktop/electron-builder.json | 4 +++- apps/desktop/package.json | 6 +++--- apps/desktop/sign.js | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 2d7d76827f1..3e1ca673c3c 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden-beta" }, @@ -62,7 +64,7 @@ "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index c42c3cc4202..83bd2921551 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden" }, @@ -176,7 +178,7 @@ "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 93d016f8791..ad20e7c0e69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -48,8 +48,8 @@ "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", - "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", - "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", @@ -62,7 +62,7 @@ "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", "publish:mac": "npm run build && npm run clean:dist && electron-builder --mac -p always", "publish:mac:mas": "npm run dist:mac:mas && npm run upload:mas", - "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "publish:win:dev": "npm run build:dev && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "upload:mas": "xcrun altool --upload-app --type osx --file \"$(find ./dist/mas-universal/Bitwarden*.pkg)\" --apiKey $APP_STORE_CONNECT_AUTH_KEY --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER", "test": "jest", diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index b8da98a882b..f115e9b8097 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -3,7 +3,7 @@ const child_process = require("child_process"); exports.default = async function (configuration) { const ext = configuration.path.split(".").at(-1); - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ext == "exe") { + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) { console.log(`[*] Signing file: ${configuration.path}`); child_process.execFileSync( "azuresigntool", From 89eb547c2db5b15429ccd7b7bcfed9d0e284a102 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:59:51 -0800 Subject: [PATCH 098/101] fix vault item dialog new item (#18330) --- .../vault-item-dialog/vault-item-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index c863608ba10..3dce773c7c1 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -96,7 +96,7 @@ [label]="'unArchive' | i18n" > } - @if (cipher.canBeArchived) { + @if (cipher?.canBeArchived) { + + `, + imports: [PopoverTriggerForDirective, PopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestPopoverTriggerComponent { + isOpen = false; + readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective }); + readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent }); + readonly templateRef = viewChild("trigger", { read: TemplateRef }); +} + +describe("PopoverTriggerForDirective", () => { + let fixture: ComponentFixture; + let component: TestPopoverTriggerComponent; + let directive: PopoverTriggerForDirective; + let overlayRef: Partial; + let overlay: Partial; + let ngZone: NgZone; + + beforeEach(async () => { + // Create mock overlay ref + overlayRef = { + backdropElement: document.createElement("div"), + attach: jest.fn(), + detach: jest.fn(), + dispose: jest.fn(), + detachments: jest.fn().mockReturnValue(new Subject()), + keydownEvents: jest.fn().mockReturnValue(new Subject()), + backdropClick: jest.fn().mockReturnValue(new Subject()), + }; + + // Create mock overlay + const mockPositionStrategy = { + flexibleConnectedTo: jest.fn().mockReturnThis(), + withPositions: jest.fn().mockReturnThis(), + withLockedPosition: jest.fn().mockReturnThis(), + withFlexibleDimensions: jest.fn().mockReturnThis(), + withPush: jest.fn().mockReturnThis(), + }; + + overlay = { + create: jest.fn().mockReturnValue(overlayRef), + position: jest.fn().mockReturnValue(mockPositionStrategy), + scrollStrategies: { + reposition: jest.fn().mockReturnValue({}), + } as any, + }; + + await TestBed.configureTestingModule({ + imports: [TestPopoverTriggerComponent], + providers: [{ provide: Overlay, useValue: overlay }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestPopoverTriggerComponent); + component = fixture.componentInstance; + ngZone = TestBed.inject(NgZone); + fixture.detectChanges(); + directive = component.directive()!; + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("Initial popover open with RAF delay", () => { + it("should use double RAF delay on first open", fakeAsync(() => { + // Spy on requestAnimationFrame to verify it's being called + const rafSpy = jest.spyOn(window, "requestAnimationFrame"); + + // Set popoverOpen signal directly on the directive inside NgZone + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + + // After effect execution, RAF should be scheduled but not executed yet + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute first RAF - tick(16) advances time by one animation frame (16ms) + // This executes the first requestAnimationFrame callback + tick(16); + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute second RAF - the nested requestAnimationFrame callback + tick(16); + expect(overlay.create).toHaveBeenCalled(); + expect(overlayRef.attach).toHaveBeenCalled(); + + rafSpy.mockRestore(); + flush(); + })); + + it("should skip RAF delay on subsequent opens", fakeAsync(() => { + // First open with double RAF delay + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Execute both RAF callbacks (16ms each = 32ms total for first open) + tick(16); // First RAF + tick(16); // Second RAF + expect(overlay.create).toHaveBeenCalledTimes(1); + jest.mocked(overlay.create).mockClear(); + + // Close by clicking + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Second open should skip RAF delay (hasInitialized is now true) + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens + tick(0); + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("Race condition prevention", () => { + it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to toggle back to false before RAF completes + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + + // Try to toggle back to true + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute RAFs + tick(16); + tick(16); + + // Should only create overlay once + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should not schedule new RAF if one is already pending", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to open again while RAF is pending (shouldn't schedule another) + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + tick(16); + tick(16); + + // Should only have created one overlay + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Click to close before RAF completes - this should cancel the RAF and prevent overlay creation + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Verify popoverOpen was set to false + expect(directive.popoverOpen()).toBe(false); + + tick(16); + tick(16); + + // Should NOT have created any overlay because RAF was canceled + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Component destruction during RAF", () => { + it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Destroy component before RAF completes + fixture.destroy(); + + // Should have cancelled animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should not create overlay if destroyed during RAF delay", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute first RAF + tick(16); + + // Destroy before second RAF + fixture.destroy(); + + // Execute second RAF (should be no-op) + tick(16); + + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + + it("should set isDestroyed flag and prevent further operations", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Destroy the component + fixture.destroy(); + + // Try to toggle (should be blocked by isDestroyed check) + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open + + flush(); + })); + }); + + describe("Click handling", () => { + it("should open popover on click when closed", fakeAsync(() => { + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(true); + expect(overlay.create).toHaveBeenCalled(); + + flush(); + })); + + it("should close popover on click when open", fakeAsync(() => { + // Open first + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Click to close + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(false); + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should not process clicks after component is destroyed", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const initialCreateCount = jest.mocked(overlay.create).mock.calls.length; + + fixture.destroy(); + + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + // Should not have created additional overlay + expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount); + + flush(); + })); + }); + + describe("Resource cleanup", () => { + it("should cancel both RAF IDs in disposeAll", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Trigger disposal while RAF is pending + directive.ngOnDestroy(); + + // Should cancel animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should dispose overlay on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlayRef.attach).toHaveBeenCalled(); + + fixture.destroy(); + + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should unsubscribe from closed events on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Get the subscription (it's private, so we'll verify via disposal) + fixture.destroy(); + + // Should have disposed overlay which triggers cleanup + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Overlay guard in openPopover", () => { + it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + // Try to open again + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("aria-expanded attribute", () => { + it("should set aria-expanded to false when closed", () => { + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it("should set aria-expanded to true when open", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + flush(); + })); + }); +}); diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index cb114f1fbc3..176a736fb39 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,12 +1,12 @@ import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { - AfterViewInit, Directive, ElementRef, HostListener, OnDestroy, ViewContainerRef, + effect, input, model, } from "@angular/core"; @@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component"; "[attr.aria-expanded]": "this.popoverOpen()", }, }) -export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { +export class PopoverTriggerForDirective implements OnDestroy { readonly popoverOpen = model(false); readonly popover = input.required({ alias: "bitPopoverTriggerFor" }); @@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private overlayRef: OverlayRef | null = null; private closedEventsSub: Subscription | null = null; + private hasInitialized = false; + private rafId1: number | null = null; + private rafId2: number | null = null; + private isDestroyed = false; get positions() { if (!this.position()) { @@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, private overlay: Overlay, - ) {} + ) { + effect(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + + if (this.hasInitialized) { + this.openPopover(); + return; + } + + if (this.rafId1 !== null || this.rafId2 !== null) { + return; + } + + // Initial open - wait for layout to stabilize + // First RAF: Waits for Angular's change detection to complete and queues the next paint + this.rafId1 = requestAnimationFrame(() => { + // Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final + this.rafId2 = requestAnimationFrame(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + this.openPopover(); + this.hasInitialized = true; + this.rafId2 = null; + }); + this.rafId1 = null; + }); + }); + } @HostListener("click") togglePopover() { + if (this.isDestroyed) { + return; + } + if (this.popoverOpen()) { this.closePopover(); } else { @@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private openPopover() { + if (this.overlayRef) { + return; + } + this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); @@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (!this.overlayRef || !this.popoverOpen()) { + if (!this.popoverOpen()) { return; } @@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.closedEventsSub = null; this.overlayRef?.dispose(); this.overlayRef = null; - } - ngAfterViewInit() { - if (this.popoverOpen()) { - this.openPopover(); + if (this.rafId1 !== null) { + cancelAnimationFrame(this.rafId1); + this.rafId1 = null; + } + if (this.rafId2 !== null) { + cancelAnimationFrame(this.rafId2); + this.rafId2 = null; } } ngOnDestroy() { + this.isDestroyed = true; this.disposeAll(); } From bea6fb26f87e815062465646ab8ee6d0ca583fe0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:42:32 +0100 Subject: [PATCH 100/101] [deps] KM: Update Rust crate rsa to v0.9.10 [SECURITY] (#18220) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Bernd Schoolmann --- apps/desktop/desktop_native/Cargo.lock | 9 ++++----- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index d4a5ccf7aca..24c280d90aa 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -1994,11 +1994,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -2653,9 +2652,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 86eb507a6c1..aecad6cb1d2 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -50,7 +50,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.6" +rsa = "=0.9.10" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" From 5fe01323bb7f1d13fe85ecdf287752bdbe3b0a52 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 13 Jan 2026 11:23:29 +0100 Subject: [PATCH 101/101] Improve routing during account switching to ensure feature flags are triggered (#18037) When account switching from the old layout it's not bringing you into the new design. This resolves it by ensuring the route guard for vault is triggered after account switching. --- apps/desktop/src/app/app-routing.module.ts | 2 ++ apps/desktop/src/app/app.component.ts | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index f75f6ccdc20..e9b6dfdc9e5 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -114,6 +114,8 @@ const routes: Routes = [ authGuard, canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false), ], + // Needed to ensure feature flag changes are picked up on account switching + runGuardsAndResolvers: "always", }, { path: "send", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index d1919c77bb5..01eb8c728e5 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -492,9 +492,8 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = true; await this.syncService.fullSync(false); this.loading = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["vault"]); + // Force reload to ensure route guards are activated + await this.router.navigate(["vault"], { onSameUrlNavigation: "reload" }); } this.messagingService.send("finishSwitchAccount"); break;