diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
index e01b4efd71b..f4b82dc56fc 100644
--- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
+++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
@@ -1,10 +1,19 @@
import { CommonModule } from "@angular/common";
-import { Component } from "@angular/core";
+import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LinkModule } from "@bitwarden/components";
+export type NavButton = {
+ label: string;
+ page: string;
+ iconKey: string;
+ iconKeyActive: string;
+ showBerry?: boolean;
+};
+
@Component({
selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html",
@@ -15,30 +24,12 @@ import { LinkModule } from "@bitwarden/components";
},
})
export class PopupTabNavigationComponent {
- navButtons = [
- {
- label: "vault",
- page: "/tabs/vault",
- iconKey: "lock",
- iconKeyActive: "lock-f",
- },
- {
- label: "generator",
- page: "/tabs/generator",
- iconKey: "generate",
- iconKeyActive: "generate-f",
- },
- {
- label: "send",
- page: "/tabs/send",
- iconKey: "send",
- iconKeyActive: "send-f",
- },
- {
- label: "settings",
- page: "/tabs/settings",
- iconKey: "cog",
- iconKeyActive: "cog-f",
- },
- ];
+ @Input() navButtons: NavButton[] = [];
+
+ constructor(private i18nService: I18nService) {}
+
+ buttonTitle(navButton: NavButton) {
+ const labelText = this.i18nService.t(navButton.label);
+ return navButton.showBerry ? this.i18nService.t("labelWithNotification", labelText) : labelText;
+ }
}
diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts
index 457198eaa4e..6fc3e11493c 100644
--- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts
+++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts
@@ -27,6 +27,7 @@ import {
ClEAR_VIEW_CACHE_COMMAND,
POPUP_VIEW_CACHE_KEY,
SAVE_VIEW_CACHE_COMMAND,
+ ViewCacheState,
} from "../../services/popup-view-cache-background.service";
/**
@@ -42,8 +43,8 @@ export class PopupViewCacheService implements ViewCacheService {
private messageSender = inject(MessageSender);
private router = inject(Router);
- private _cache: Record
;
- private get cache(): Record {
+ private _cache: Record;
+ private get cache(): Record {
if (!this._cache) {
throw new Error("Dirty View Cache not initialized");
}
@@ -64,15 +65,9 @@ export class PopupViewCacheService implements ViewCacheService {
filter((e) => e instanceof NavigationEnd),
/** Skip the first navigation triggered by `popupRouterCacheGuard` */
skip(1),
- filter((e: NavigationEnd) =>
- // viewing/editing a cipher and navigating back to the vault list should not clear the cache
- ["/view-cipher", "/edit-cipher", "/tabs/vault"].every(
- (route) => !e.urlAfterRedirects.startsWith(route),
- ),
- ),
)
- .subscribe((e) => {
- return this.clearState();
+ .subscribe(() => {
+ return this.clearState(true);
});
}
@@ -85,13 +80,20 @@ export class PopupViewCacheService implements ViewCacheService {
key,
injector = inject(Injector),
initialValue,
+ persistNavigation,
} = options;
- const cachedValue = this.cache[key] ? deserializer(JSON.parse(this.cache[key])) : initialValue;
+ const cachedValue = this.cache[key]
+ ? deserializer(JSON.parse(this.cache[key].value))
+ : initialValue;
const _signal = signal(cachedValue);
+ const viewCacheOptions = {
+ ...(persistNavigation && { persistNavigation }),
+ };
+
effect(
() => {
- this.updateState(key, JSON.stringify(_signal()));
+ this.updateState(key, JSON.stringify(_signal()), viewCacheOptions);
},
{ injector },
);
@@ -123,15 +125,24 @@ export class PopupViewCacheService implements ViewCacheService {
return control;
}
- private updateState(key: string, value: string) {
+ private updateState(key: string, value: string, options: ViewCacheState["options"]) {
this.messageSender.send(SAVE_VIEW_CACHE_COMMAND, {
key,
value,
+ options,
});
}
- private clearState() {
- this._cache = {}; // clear local cache
- this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {});
+ private clearState(routeChange: boolean = false) {
+ if (routeChange) {
+ // Only keep entries with `persistNavigation`
+ this._cache = Object.fromEntries(
+ Object.entries(this._cache).filter(([, { options }]) => options?.persistNavigation),
+ );
+ } else {
+ // Clear all entries
+ this._cache = {};
+ }
+ this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, { routeChange: routeChange });
}
}
diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts
index b6009c4cc2e..2ec75791d1b 100644
--- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts
+++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts
@@ -14,6 +14,7 @@ import {
ClEAR_VIEW_CACHE_COMMAND,
POPUP_VIEW_CACHE_KEY,
SAVE_VIEW_CACHE_COMMAND,
+ ViewCacheState,
} from "../../services/popup-view-cache-background.service";
import { PopupViewCacheService } from "./popup-view-cache.service";
@@ -35,6 +36,7 @@ export class TestComponent {
signal = this.viewCacheService.signal({
key: "test-signal",
initialValue: "initial signal",
+ persistNavigation: true,
});
}
@@ -42,11 +44,11 @@ describe("popup view cache", () => {
const configServiceMock = mock();
let testBed: TestBed;
let service: PopupViewCacheService;
- let fakeGlobalState: FakeGlobalState>;
+ let fakeGlobalState: FakeGlobalState>;
let messageSenderMock: MockProxy;
let router: Router;
- const initServiceWithState = async (state: Record) => {
+ const initServiceWithState = async (state: Record) => {
await fakeGlobalState.update(() => state);
await service.init();
};
@@ -106,7 +108,11 @@ describe("popup view cache", () => {
});
it("should initialize signal from state", async () => {
- await initServiceWithState({ "foo-123": JSON.stringify("bar") });
+ await initServiceWithState({
+ "foo-123": {
+ value: JSON.stringify("bar"),
+ },
+ });
const injector = TestBed.inject(Injector);
@@ -120,7 +126,11 @@ describe("popup view cache", () => {
});
it("should initialize form from state", async () => {
- await initServiceWithState({ "test-form-cache": JSON.stringify({ name: "baz" }) });
+ await initServiceWithState({
+ "test-form-cache": {
+ value: JSON.stringify({ name: "baz" }),
+ },
+ });
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentRef.instance;
@@ -138,7 +148,11 @@ describe("popup view cache", () => {
});
it("should utilize deserializer", async () => {
- await initServiceWithState({ "foo-123": JSON.stringify("bar") });
+ await initServiceWithState({
+ "foo-123": {
+ value: JSON.stringify("bar"),
+ },
+ });
const injector = TestBed.inject(Injector);
@@ -178,6 +192,9 @@ describe("popup view cache", () => {
expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, {
key: "test-signal",
value: JSON.stringify("Foobar"),
+ options: {
+ persistNavigation: true,
+ },
});
});
@@ -192,18 +209,63 @@ describe("popup view cache", () => {
expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, {
key: "test-form-cache",
value: JSON.stringify({ name: "Foobar" }),
+ options: {},
});
});
it("should clear on 2nd navigation", async () => {
- await initServiceWithState({ temp: "state" });
+ await initServiceWithState({
+ temp: {
+ value: "state",
+ options: {},
+ },
+ });
await router.navigate(["a"]);
expect(messageSenderMock.send).toHaveBeenCalledTimes(0);
- expect(service["_cache"]).toEqual({ temp: "state" });
+ expect(service["_cache"]).toEqual({
+ temp: {
+ value: "state",
+ options: {},
+ },
+ });
await router.navigate(["b"]);
- expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {});
+ expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {
+ routeChange: true,
+ });
expect(service["_cache"]).toEqual({});
});
+
+ it("should respect persistNavigation setting on 2nd navigation", async () => {
+ await initServiceWithState({
+ keepState: {
+ value: "state",
+ options: {
+ persistNavigation: true,
+ },
+ },
+ removeState: {
+ value: "state",
+ options: {
+ persistNavigation: false,
+ },
+ },
+ });
+
+ await router.navigate(["a"]); // first navigation covered in previous test
+
+ await router.navigate(["b"]);
+ expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {
+ routeChange: true,
+ });
+ expect(service["_cache"]).toEqual({
+ keepState: {
+ value: "state",
+ options: {
+ persistNavigation: true,
+ },
+ },
+ });
+ });
});
diff --git a/apps/browser/src/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts
index 98a6065189b..79c04e90aad 100644
--- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts
+++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts
@@ -16,8 +16,27 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
const popupClosedPortName = "new_popup";
+export type ViewCacheOptions = {
+ /**
+ * Optional flag to persist the cached value between navigation events.
+ */
+ persistNavigation?: boolean;
+};
+
+export type ViewCacheState = {
+ /**
+ * The cached value
+ */
+ value: string; // JSON value
+
+ /**
+ * Options for managing/clearing the cache
+ */
+ options?: ViewCacheOptions;
+};
+
/** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */
-export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record(
+export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record(
POPUP_VIEW_MEMORY,
"popup-view-cache",
{
@@ -36,9 +55,15 @@ export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition(
export const SAVE_VIEW_CACHE_COMMAND = new CommandDefinition<{
key: string;
value: string;
+ options: ViewCacheOptions;
}>("save-view-cache");
-export const ClEAR_VIEW_CACHE_COMMAND = new CommandDefinition("clear-view-cache");
+export const ClEAR_VIEW_CACHE_COMMAND = new CommandDefinition<{
+ /**
+ * Flag to indicate the clear request was triggered by a route change in popup.
+ */
+ routeChange: boolean;
+}>("clear-view-cache");
export class PopupViewCacheBackgroundService {
private popupViewCacheState = this.globalStateProvider.get(POPUP_VIEW_CACHE_KEY);
@@ -61,10 +86,13 @@ export class PopupViewCacheBackgroundService {
this.messageListener
.messages$(SAVE_VIEW_CACHE_COMMAND)
.pipe(
- concatMap(async ({ key, value }) =>
+ concatMap(async ({ key, value, options }) =>
this.popupViewCacheState.update((state) => ({
...state,
- [key]: value,
+ [key]: {
+ value,
+ options,
+ },
})),
),
)
@@ -72,7 +100,19 @@ export class PopupViewCacheBackgroundService {
this.messageListener
.messages$(ClEAR_VIEW_CACHE_COMMAND)
- .pipe(concatMap(() => this.popupViewCacheState.update(() => null)))
+ .pipe(
+ concatMap(({ routeChange }) =>
+ this.popupViewCacheState.update((state) => {
+ if (routeChange && state) {
+ // Only remove keys that are not marked with `persistNavigation`
+ return Object.fromEntries(
+ Object.entries(state).filter(([, { options }]) => options?.persistNavigation),
+ );
+ }
+ return null;
+ }),
+ ),
+ )
.subscribe();
// on popup closed, with 2 minute delay that is cancelled by re-opening the popup
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index 8ebf6eb6110..45955506b91 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -55,7 +55,6 @@ import {
ExtensionAnonLayoutWrapperComponent,
ExtensionAnonLayoutWrapperData,
} from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
-import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
@@ -65,6 +64,7 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
+import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
@@ -593,6 +593,7 @@ const routes: Routes = [
path: "intro-carousel",
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [],
+ data: { elevation: 0, doNotSaveUrl: true } satisfies RouteDataProperties,
children: [
{
path: "",
diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts
index 0d392afa63b..8bea41da4d6 100644
--- a/apps/browser/src/popup/app.module.ts
+++ b/apps/browser/src/popup/app.module.ts
@@ -25,13 +25,13 @@ import {
import { AccountComponent } from "../auth/popup/account-switching/account.component";
import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component";
import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
-import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
+import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { PopOutComponent } from "../platform/popup/components/pop-out.component";
import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
diff --git a/apps/browser/src/popup/tabs-v2.component.html b/apps/browser/src/popup/tabs-v2.component.html
new file mode 100644
index 00000000000..bde3aaa3d31
--- /dev/null
+++ b/apps/browser/src/popup/tabs-v2.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts
index 4cdb8fc029d..1392dc565ab 100644
--- a/apps/browser/src/popup/tabs-v2.component.ts
+++ b/apps/browser/src/popup/tabs-v2.component.ts
@@ -1,11 +1,53 @@
import { Component } from "@angular/core";
+import { combineLatest, map } from "rxjs";
+
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { HasNudgeService } from "@bitwarden/vault";
@Component({
selector: "app-tabs-v2",
- template: `
-
-
-
- `,
+ templateUrl: "./tabs-v2.component.html",
+ providers: [HasNudgeService],
})
-export class TabsV2Component {}
+export class TabsV2Component {
+ constructor(
+ private readonly hasNudgeService: HasNudgeService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ protected navButtons$ = combineLatest([
+ this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge),
+ this.hasNudgeService.shouldShowNudge$(),
+ ]).pipe(
+ map(([onboardingFeatureEnabled, showNudge]) => {
+ return [
+ {
+ label: "vault",
+ page: "/tabs/vault",
+ iconKey: "lock",
+ iconKeyActive: "lock-f",
+ },
+ {
+ label: "generator",
+ page: "/tabs/generator",
+ iconKey: "generate",
+ iconKeyActive: "generate-f",
+ },
+ {
+ label: "send",
+ page: "/tabs/send",
+ iconKey: "send",
+ iconKeyActive: "send-f",
+ },
+ {
+ label: "settings",
+ page: "/tabs/settings",
+ iconKey: "cog",
+ iconKeyActive: "cog-f",
+ showBerry: onboardingFeatureEnabled && showNudge,
+ },
+ ];
+ }),
+ );
+}
diff --git a/apps/browser/src/safari/desktop/Info.plist b/apps/browser/src/safari/desktop/Info.plist
index 69ea518a0ae..b687d9d2f3a 100644
--- a/apps/browser/src/safari/desktop/Info.plist
+++ b/apps/browser/src/safari/desktop/Info.plist
@@ -25,7 +25,7 @@
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
NSHumanReadableCopyright
- Copyright © 2015-2024 Bitwarden Inc. All rights reserved.
+ Copyright © 2015-2025 Bitwarden Inc. All rights reserved.
NSMainStoryboardFile
Main
NSPrincipalClass
diff --git a/apps/browser/src/safari/safari/Info.plist b/apps/browser/src/safari/safari/Info.plist
index b79ed132ea9..95172846758 100644
--- a/apps/browser/src/safari/safari/Info.plist
+++ b/apps/browser/src/safari/safari/Info.plist
@@ -30,7 +30,7 @@
$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler
NSHumanReadableCopyright
- Copyright © 2015-2024 Bitwarden Inc. All rights reserved.
+ Copyright © 2015-2025 Bitwarden Inc. All rights reserved.
NSHumanReadableDescription
A secure and free password manager for all of your devices.
SFSafariAppExtensionBundleIdentifiersToReplace
diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts
index 25bf3ce3716..ff583061684 100644
--- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts
@@ -12,10 +12,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
+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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
+import { NotificationView } from "@bitwarden/common/vault/notifications/models";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService, ToastService } from "@bitwarden/components";
import {
@@ -66,6 +69,7 @@ describe("AtRiskPasswordsComponent", () => {
let mockTasks$: BehaviorSubject;
let mockCiphers$: BehaviorSubject;
let mockOrgs$: BehaviorSubject;
+ let mockNotifications$: BehaviorSubject;
let mockInlineMenuVisibility$: BehaviorSubject;
let calloutDismissed$: BehaviorSubject;
const setInlineMenuVisibility = jest.fn();
@@ -73,6 +77,7 @@ describe("AtRiskPasswordsComponent", () => {
const mockAtRiskPasswordPageService = mock();
const mockChangeLoginPasswordService = mock();
const mockDialogService = mock();
+ const mockConfigService = mock();
beforeEach(async () => {
mockTasks$ = new BehaviorSubject([
@@ -101,6 +106,7 @@ describe("AtRiskPasswordsComponent", () => {
name: "Org 1",
} as Organization,
]);
+ mockNotifications$ = new BehaviorSubject([]);
mockInlineMenuVisibility$ = new BehaviorSubject(
AutofillOverlayVisibility.Off,
@@ -110,6 +116,7 @@ describe("AtRiskPasswordsComponent", () => {
setInlineMenuVisibility.mockClear();
mockToastService.showToast.mockClear();
mockDialogService.open.mockClear();
+ mockConfigService.getFeatureFlag.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
await TestBed.configureTestingModule({
@@ -133,6 +140,12 @@ describe("AtRiskPasswordsComponent", () => {
cipherViews$: () => mockCiphers$,
},
},
+ {
+ provide: EndUserNotificationService,
+ useValue: {
+ unreadNotifications$: () => mockNotifications$,
+ },
+ },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
{ provide: PlatformUtilsService, useValue: mock() },
@@ -145,6 +158,7 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: ToastService, useValue: mockToastService },
+ { provide: ConfigService, useValue: mockConfigService },
],
})
.overrideModule(JslibModule, {
diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts
index 37c445f6c30..1b43151193a 100644
--- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts
+++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts
@@ -1,7 +1,19 @@
import { CommonModule } from "@angular/common";
-import { Component, inject, OnInit, signal } from "@angular/core";
+import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
-import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
+import {
+ combineLatest,
+ concat,
+ concatMap,
+ firstValueFrom,
+ map,
+ of,
+ shareReplay,
+ startWith,
+ switchMap,
+ take,
+} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -11,10 +23,13 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
@@ -81,6 +96,9 @@ export class AtRiskPasswordsComponent implements OnInit {
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
private platformUtilsService = inject(PlatformUtilsService);
private dialogService = inject(DialogService);
+ private endUserNotificationService = inject(EndUserNotificationService);
+ private configService = inject(ConfigService);
+ private destroyRef = inject(DestroyRef);
/**
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
@@ -180,6 +198,36 @@ export class AtRiskPasswordsComponent implements OnInit {
await this.atRiskPasswordPageService.dismissGettingStarted(userId);
}
}
+
+ if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) {
+ this.markTaskNotificationsAsRead();
+ }
+ }
+
+ private markTaskNotificationsAsRead() {
+ this.activeUserData$
+ .pipe(
+ switchMap(({ tasks, userId }) => {
+ return this.endUserNotificationService.unreadNotifications$(userId).pipe(
+ take(1),
+ map((notifications) => {
+ return notifications.filter((notification) => {
+ return tasks.some((task) => task.id === notification.taskId);
+ });
+ }),
+ concatMap((unreadTaskNotifications) => {
+ // TODO: Investigate creating a bulk endpoint to mark notifications as read
+ return concat(
+ ...unreadTaskNotifications.map((n) =>
+ this.endUserNotificationService.markAsRead(n.id, userId),
+ ),
+ );
+ }),
+ );
+ }),
+ takeUntilDestroyed(this.destroyRef),
+ )
+ .subscribe();
}
async viewCipher(cipher: CipherView) {
diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html
index 3c061109945..ff7bf25b86b 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html
@@ -43,7 +43,7 @@
bitButton
buttonType="secondary"
(click)="navigateToLogin()"
- class="tw-w-full tw-mt-4"
+ class="tw-w-full tw-mt-2"
>
{{ "logIn" | i18n }}
diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts
index 5e400da9de5..ae6369d06a5 100644
--- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts
+++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts
@@ -1,5 +1,3 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
import { inject } from "@angular/core";
import { Router } from "@angular/router";
diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
index 5f2ec858ed6..b4cf79e7422 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
@@ -50,6 +50,7 @@ export class VaultPopupItemsService {
private cachedSearchText = inject(PopupViewCacheService).signal({
key: "vault-search-text",
initialValue: "",
+ persistNavigation: true,
});
readonly searchText$ = toObservable(this.cachedSearchText);
diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
index 6cce5796cbe..f11fa0f63f0 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
@@ -188,6 +188,7 @@ export class VaultPopupListFiltersService {
key: "vault-filters",
initialValue: {},
deserializer: (v) => v,
+ persistNavigation: true,
});
this.deserializeFilters(cachedFilters());
diff --git a/apps/browser/store/locales/ar/copy.resx b/apps/browser/store/locales/ar/copy.resx
index 9fdfb942100..a83bafbf1ae 100644
--- a/apps/browser/store/locales/ar/copy.resx
+++ b/apps/browser/store/locales/ar/copy.resx
@@ -118,58 +118,60 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Bitwarden Password Manager
+ مدير كلمات المرور بتواردن
- At home, at work, or on the go, Bitwarden easily secures all your passwords, passkeys, and sensitive information.
+ في المنزل، في العمل، أو في أثناء التنقل، يقوم بتواردن بتأمين جميع كلمات المرور والمعلومات الحساسة بسهولة.
- Recognized as the best password manager by PCMag, WIRED, The Verge, CNET, G2, and more!
+ مُعترف به كأفضل مدير كلمات مرور من قِبل PCMag وWIRED وThe Verge وCNET وG2 وغيرها!
-SECURE YOUR DIGITAL LIFE
-Secure your digital life and protect against data breaches by generating and saving unique, strong passwords for every account. Maintain everything in an end-to-end encrypted password vault that only you can access.
+أمّن حياتك الرقمية
-ACCESS YOUR DATA, ANYWHERE, ANYTIME, ON ANY DEVICE
-Easily manage, store, secure, and share unlimited passwords across unlimited devices without restrictions.
+أمّن حياتك الرقمية واحمِ بياناتك من الاختراقات بإنشاء كلمات مرور فريدة وقوية وحفظها لكل حساب. احفظ كل شيء في مخزن كلمات مرور مشفّر من البداية إلى النهاية، لا يمكن لأحد الوصول إليه سواك.
-EVERYONE SHOULD HAVE THE TOOLS TO STAY SAFE ONLINE
-Utilize Bitwarden for free with no ads or selling data. Bitwarden believes everyone should have the ability to stay safe online. Premium plans offer access to advanced features.
+الوصول إلى بياناتك، من أي مكان، وفي أي وقت، وعلى أي جهاز
-EMPOWER YOUR TEAMS WITH BITWARDEN
-Plans for Teams and Enterprise come with professional business features. Some examples include SSO integration, self-hosting, directory integration and SCIM provisioning, global policies, API access, event logs, and more.
+يمكنك بسهولة إدارة كلمات مرور غير محدودة وتخزينها وتأمينها ومشاركتها عبر عدد غير محدود من الأجهزة دون قيود.
-Use Bitwarden to secure your workforce and share sensitive information with colleagues.
+يجب أن يمتلك الجميع الأدوات اللازمة للبقاء آمنًا على الإنترنت
+استخدم بيتواردن مجانًا دون إعلانات أو بيع بيانات. تؤمن بيتواردن بحق الجميع في البقاء آمنًا على الإنترنت. توفر الباقات المميزة إمكانية الوصول إلى ميزات متقدمة.
-More reasons to choose Bitwarden:
+عزز قدرات فرقك مع بتواردن
+تأتي باقاتنا للفرق والمؤسسات مزودة بميزات احترافية للأعمال. من الأمثلة على ذلك تكامل SSO، والاستضافة الذاتية، وتكامل الدليل، وتوفير SCIM، والسياسات العالمية، والوصول إلى واجهة برمجة التطبيقات، وسجلات الأحداث، والمزيد.
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+استخدم بيتواردن لتأمين فريق عملك ومشاركة المعلومات الحساسة مع زملائك.
-3rd-party Audits
-Bitwarden regularly conducts comprehensive third-party security audits with notable security firms. These annual audits include source code assessments and penetration testing across Bitwarden IPs, servers, and web applications.
+أسباب إضافية لاختيار بتواردن:
-Advanced 2FA
-Secure your login with a third-party authenticator, emailed codes, or FIDO2 WebAuthn credentials such as a hardware security key or passkey.
+تشفير عالمي المستوى
+كلمات المرور محمية بتشفير متقدم من البداية إلى النهاية (AES-256 بت، وهاشتاج مُملح، وPBKDF2 SHA-256) لضمان أمان بياناتك وخصوصيتها.
-Bitwarden Send
-Transmit data directly to others while maintaining end-to-end encrypted security and limiting exposure.
+عمليات تدقيق خارجية
+تُجري بيتواردن بانتظام عمليات تدقيق أمنية شاملة من جهات خارجية بالتعاون مع شركات أمنية مرموقة. تشمل هذه العمليات السنوية تقييمات لشفرة المصدر واختبارات اختراق عبر عناوين IP وخوادم وتطبيقات الويب الخاصة بـبتواردن.
-Built-in Generator
-Create long, complex, and distinct passwords and unique usernames for every site you visit. Integrate with email alias providers for additional privacy.
+مصادقة ثنائية متقدمة
+أمّن تسجيل دخولك باستخدام مُصادق خارجي، أو رموز مُرسلة عبر البريد الإلكتروني، أو بيانات اعتماد FIDO2 WebAuthn مثل مفتاح أمان الأجهزة أو كلمة المرور.
-Global Translations
-Bitwarden translations exist for more than 60 languages, translated by the global community though Crowdin.
+إرسال بتواردن
+انقل البيانات مباشرةً إلى الآخرين مع الحفاظ على أمان مشفّر من البداية إلى النهاية والحد من التعرض.
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
+مولد مدمج
+أنشئ كلمات مرور طويلة ومعقدة ومميزة وأسماء مستخدمين فريدة لكل موقع تزوره. تكامل مع مزودي أسماء البريد الإلكتروني المستعارة لمزيد من الخصوصية.
-Bitwarden secures more than just passwords
-End-to-end encrypted credential management solutions from Bitwarden empower organizations to secure everything, including developer secrets and passkey experiences. Visit Bitwarden.com to learn more about Bitwarden Secrets Manager and Bitwarden Passwordless.dev!
+ترجمات عالمية
+تتوفر ترجمات بتواردن لأكثر من 60 لغة، مترجمة من قِبل المجتمع العالمي عبر Crowdin.
+
+تطبيقات متعددة المنصات
+أمّن بياناتك الحساسة وشاركها داخل مخزن بتواردن من أي متصفح أو جهاز محمول أو نظام تشغيل سطح مكتب، وغير ذلك الكثير.
+
+يؤمن بتواردن أكثر من مجرد كلمات مرور
+تُمكّن حلول إدارة بيانات الاعتماد المشفرة من البداية إلى النهاية من بتواردن المؤسسات من تأمين كل شيء، بما في ذلك أسرار المطورين وتجارب مفاتيح المرور. تفضل بزيارة Bitwarden.com لمعرفة المزيد عن المدير السري لبتواردن وBitwarden Passwordless.dev!
- At home, at work, or on the go, Bitwarden easily secures all your passwords, passkeys, and sensitive information.
+ في المنزل، في العمل، أو في أثناء التنقل، يقوم بتواردن بتأمين جميع كلمات المرور والمعلومات الحساسة بسهولة.
مزامنة خزانتك والوصول إليها من عدة أجهزة
diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js
index 09d1133a4df..4b66ed7d70a 100644
--- a/apps/browser/webpack.config.js
+++ b/apps/browser/webpack.config.js
@@ -135,16 +135,6 @@ const plugins = [
filename: "overlay/menu.html",
chunks: ["overlay/menu"],
}),
- new HtmlWebpackPlugin({
- template: "./src/autofill/deprecated/overlay/pages/button/legacy-button.html",
- filename: "overlay/button.html",
- chunks: ["overlay/button"],
- }),
- new HtmlWebpackPlugin({
- template: "./src/autofill/deprecated/overlay/pages/list/legacy-list.html",
- filename: "overlay/list.html",
- chunks: ["overlay/list"],
- }),
new CopyWebpackPlugin({
patterns: [
{
@@ -197,8 +187,6 @@ const mainConfig = {
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
"content/bootstrap-autofill-overlay-notifications":
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
- "content/bootstrap-legacy-autofill-overlay":
- "./src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
@@ -213,10 +201,6 @@ const mainConfig = {
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
"overlay/menu":
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
- "overlay/button":
- "./src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts",
- "overlay/list":
- "./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts",
"encrypt-worker": "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts",
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 7d9f4af0ffe..daec6593543 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
- "version": "2025.3.0",
+ "version": "2025.4.0",
"keywords": [
"bitwarden",
"password",
@@ -34,18 +34,22 @@
"dist:oss:mac": "npm run build:oss:prod && npm run clean && npm run package:oss:mac",
"dist:oss:mac-arm64": "npm run build:oss:prod && npm run clean && npm run package:oss:mac-arm64",
"dist:oss:lin": "npm run build:oss:prod && npm run clean && npm run package:oss:lin",
+ "dist:oss:lin-arm64": "npm run build:oss:prod && npm run clean && npm run package:oss:lin-arm64",
"dist:bit:win": "npm run build:bit:prod && npm run clean && npm run package:bit:win",
"dist:bit:mac": "npm run build:bit:prod && npm run clean && npm run package:bit:mac",
"dist:bit:mac-arm64": "npm run build:bit:prod && npm run clean && npm run package:bit:mac-arm64",
"dist:bit:lin": "npm run build:bit:prod && npm run clean && npm run package:bit:lin",
+ "dist:bit:lin-arm64": "npm run build:bit:prod && npm run clean && npm run package:bit:lin-arm64",
"package:oss:win": "pkg . --targets win-x64 --output ./dist/oss/windows/bw.exe",
"package:oss:mac": "pkg . --targets macos-x64 --output ./dist/oss/macos/bw",
"package:oss:mac-arm64": "pkg . --targets macos-arm64 --output ./dist/oss/macos-arm64/bw",
"package:oss:lin": "pkg . --targets linux-x64 --output ./dist/oss/linux/bw",
+ "package:oss:lin-arm64": "pkg . --targets linux-arm64 --output ./dist/oss/linux-arm64/bw",
"package:bit:win": "pkg . --targets win-x64 --output ./dist/bit/windows/bw.exe",
"package:bit:mac": "pkg . --targets macos-x64 --output ./dist/bit/macos/bw",
"package:bit:mac-arm64": "pkg . --targets macos-arm64 --output ./dist/bit/macos-arm64/bw",
"package:bit:lin": "pkg . --targets linux-x64 --output ./dist/bit/linux/bw",
+ "package:bit:lin-arm64": "pkg . --targets linux-arm64 --output ./dist/bit/linux-arm64/bw",
"test": "jest",
"test:watch": "jest --watch",
"test:watch:all": "jest --watchAll"
@@ -70,9 +74,9 @@
"form-data": "4.0.1",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
- "jsdom": "26.0.0",
+ "jsdom": "26.1.0",
"jszip": "3.10.1",
- "koa": "2.15.4",
+ "koa": "2.16.1",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lowdb": "1.0.0",
diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/auth/commands/unlock.command.ts
index 6d524759dd6..d10c3f38ebd 100644
--- a/apps/cli/src/auth/commands/unlock.command.ts
+++ b/apps/cli/src/auth/commands/unlock.command.ts
@@ -17,7 +17,7 @@ import { MasterKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
-import { ConvertToKeyConnectorCommand } from "../../commands/convert-to-key-connector.command";
+import { ConvertToKeyConnectorCommand } from "../../key-management/convert-to-key-connector.command";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
import { CliUtils } from "../../utils";
diff --git a/apps/cli/src/commands/convert-to-key-connector.command.ts b/apps/cli/src/key-management/convert-to-key-connector.command.ts
similarity index 100%
rename from apps/cli/src/commands/convert-to-key-connector.command.ts
rename to apps/cli/src/key-management/convert-to-key-connector.command.ts
diff --git a/apps/cli/src/tools/export.command.ts b/apps/cli/src/tools/export.command.ts
index f5fea794eef..3fbc466efe0 100644
--- a/apps/cli/src/tools/export.command.ts
+++ b/apps/cli/src/tools/export.command.ts
@@ -10,8 +10,6 @@ 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 { EventType } from "@bitwarden/common/enums";
-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 {
ExportFormat,
@@ -30,7 +28,6 @@ export class ExportCommand {
private policyService: PolicyService,
private eventCollectionService: EventCollectionService,
private accountService: AccountService,
- private configService: ConfigService,
) {}
async run(options: OptionValues): Promise {
@@ -55,13 +52,6 @@ export class ExportCommand {
const format =
password && options.format == "json" ? "encrypted_json" : (options.format ?? "csv");
- if (
- format == "zip" &&
- !(await this.configService.getFeatureFlag(FeatureFlag.ExportAttachments))
- ) {
- return Response.badRequest("Exporting attachments is not supported in this environment.");
- }
-
if (!this.isSupportedExportFormat(format)) {
return Response.badRequest(
`'${format}' is not a supported export format. Supported formats: ${EXPORT_FORMATS.join(
diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts
index c004d3597c1..ce6ac2af94e 100644
--- a/apps/cli/src/vault.program.ts
+++ b/apps/cli/src/vault.program.ts
@@ -464,7 +464,7 @@ export class VaultProgram extends BaseProgram {
private exportCommand(): Command {
return new Command("export")
- .description("Export vault data to a CSV or JSON file.")
+ .description("Export vault data to a CSV, JSON or ZIP file.")
.option("--output