1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

This commit is contained in:
John Harrington
2025-12-03 13:22:44 -07:00
committed by GitHub
101 changed files with 2473 additions and 254 deletions

View File

@@ -1406,6 +1406,27 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"later": {
"message": "Later"
},
"authenticatorKeyTotp": {
"message": "Authenticator key (TOTP)"
},

View File

@@ -0,0 +1,193 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { platformPopoutGuard } from "./platform-popout.guard";
describe("platformPopoutGuard", () => {
let getPlatformInfoSpy: jest.SpyInstance;
let inPopoutSpy: jest.SpyInstance;
let inSidebarSpy: jest.SpyInstance;
let openPopoutSpy: jest.SpyInstance;
let closePopupSpy: jest.SpyInstance;
const mockRoute = {} as ActivatedRouteSnapshot;
const mockState: RouterStateSnapshot = {
url: "/login-with-passkey?param=value",
} as RouterStateSnapshot;
beforeEach(() => {
getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo");
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
TestBed.configureTestingModule({});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when platform matches", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout and block navigation when not already in popout or sidebar", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(inPopoutSpy).toHaveBeenCalledWith(window);
expect(inSidebarSpy).toHaveBeenCalledWith(window);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should allow navigation when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("should allow navigation when already in sidebar", async () => {
inSidebarSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when platform does not match", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should allow navigation without opening popout", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when forcePopout is true", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout regardless of platform", async () => {
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should not open popout when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("with multiple platforms", () => {
beforeEach(() => {
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it.each(["linux", "mac", "win"])(
"should open popout when platform is %s and included in platforms array",
async (platform) => {
getPlatformInfoSpy.mockResolvedValue({ os: platform });
const guard = platformPopoutGuard(["linux", "mac", "win"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
},
);
it("should not open popout when platform is not in the array", async () => {
getPlatformInfoSpy.mockResolvedValue({ os: "android" });
const guard = platformPopoutGuard(["linux", "mac"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("url handling", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should preserve query parameters in the popout url", async () => {
const stateWithQuery: RouterStateSnapshot = {
url: "/path?foo=bar&baz=qux",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
it("should handle urls without query parameters", async () => {
const stateWithoutQuery: RouterStateSnapshot = {
url: "/simple-path",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/simple-path?autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
});
});

View File

@@ -0,0 +1,46 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
/**
* Guard that forces a popout window for specific platforms.
* Useful when popup context would close during operations (e.g., WebAuthn on Linux).
*
* @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"])
* @param forcePopout - If true, always force popout regardless of platform (useful for testing)
* @returns CanActivateFn that opens popout and blocks navigation if conditions met
*/
export function platformPopoutGuard(
platforms: string[],
forcePopout: boolean = false,
): CanActivateFn {
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
// Check if current platform matches
const platformInfo = await BrowserApi.getPlatformInfo();
const isPlatformMatch = platforms.includes(platformInfo.os);
// Check if already in popout/sidebar
const inPopout = BrowserPopupUtils.inPopout(window);
const inSidebar = BrowserPopupUtils.inSidebar(window);
// Open popout if conditions met
if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) {
// Add autoClosePopout query param to signal the popout should close after completion
const [path, existingQuery] = state.url.split("?");
const params = new URLSearchParams(existingQuery || "");
params.set("autoClosePopout", "true");
const urlWithAutoClose = `${path}?${params.toString()}`;
// Open the popout window
await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`);
// Close the original popup window
BrowserApi.closePopup(window);
return false; // Block navigation - popout will reload
}
return true; // Allow navigation
};
}

View File

@@ -658,7 +658,7 @@ export default class NotificationBackground {
if (
username !== null &&
newPassword === null &&
cipher.login.username === normalizedUsername &&
cipher.login.username.toLowerCase() === normalizedUsername &&
cipher.login.password === currentPassword
) {
// Assumed to be a login

View File

@@ -262,11 +262,30 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
return (
!modifyLoginData ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change)
if (!modifyLoginData) {
return true;
}
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
);
if (shouldAttemptAddNotification) {
return false;
}
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
);
if (shouldAttemptChangeNotification) {
return false;
}
return false;
};
/**
@@ -454,15 +473,27 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
): boolean => {
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
const passwordFieldHasValue = !!(modifyLoginData?.password || "").length;
const newPasswordFieldHasValue = !!(modifyLoginData?.newPassword || "").length;
const canBeUserLogin = usernameFieldHasValue && passwordFieldHasValue;
const canBePasswordUpdate = passwordFieldHasValue && newPasswordFieldHasValue;
switch (notificationType) {
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
return (
modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword)
);
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
return !!(modifyLoginData.password || modifyLoginData.newPassword);
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
return !modifyLoginData.newPassword;
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;

View File

@@ -39,6 +39,7 @@ export class AutoFillConstants {
"otpcode",
"onetimepassword",
"security_code",
"second-factor",
"twofactor",
"twofa",
"twofactorcode",

View File

@@ -1603,14 +1603,14 @@ describe("AutofillOverlayContentService", () => {
it("skips triggering submission if a button is not found", async () => {
const submitButton = document.querySelector("button");
submitButton.remove();
submitButton?.remove();
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1627,7 +1627,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1641,7 +1641,7 @@ describe("AutofillOverlayContentService", () => {
<div id="shadow-root"></div>
<button id="button-el">Change Password</button>
</div>`;
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" });
const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<input type="password" id="password-field-1" placeholder="new password" />
`;
@@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("refreshMenuLayerPosition", () => {
it("calls refreshTopLayerPosition on the inline menu content service", () => {
autofillOverlayContentService.refreshMenuLayerPosition();
expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled();
});
it("does not throw if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow();
});
});
describe("getOwnedInlineMenuTagNames", () => {
it("returns tag names from the inline menu content service", () => {
inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]);
const result = autofillOverlayContentService.getOwnedInlineMenuTagNames();
expect(result).toEqual(["div", "span"]);
});
it("returns an empty array if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames();
expect(result).toEqual([]);
});
});
describe("getUnownedTopLayerItems", () => {
it("returns unowned top layer items from the inline menu content service", () => {
const mockElements = document.querySelectorAll("div");
inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements);
const result = autofillOverlayContentService.getUnownedTopLayerItems(true);
expect(result).toEqual(mockElements);
expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true);
});
it("returns undefined if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getUnownedTopLayerItems();
expect(result).toBeUndefined();
});
});
describe("clearUserFilledFields", () => {
it("deletes all user filled fields", () => {
const mockElement1 = document.createElement("input") as FillableFormFieldElement;
const mockElement2 = document.createElement("input") as FillableFormFieldElement;
autofillOverlayContentService["userFilledFields"] = {
username: mockElement1,
password: mockElement2,
};
autofillOverlayContentService.clearUserFilledFields();
expect(autofillOverlayContentService["userFilledFields"]).toEqual({});
});
});
describe("handleOverlayRepositionEvent", () => {
const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE];
repositionEvents.forEach((repositionEvent) => {
@@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => {
});
it("skips focusing an element if no recently focused field exists", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
(autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null;
sendMockExtensionMessage({
command: "redirectAutofillInlineMenuFocusOut",
@@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => {
});
it("returns null if the sub frame URL cannot be parsed correctly", async () => {
delete globalThis.location;
globalThis.location = { href: "invalid-base" } as Location;
sendMockExtensionMessage(
{

View File

@@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService
!fieldType ||
!this.usernameFieldTypes.has(fieldType) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field)
this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) {
return false;
}

View File

@@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
@@ -414,7 +415,7 @@ const routes: Routes = [
},
{
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
pageTitle: {

View File

@@ -381,4 +381,88 @@ describe("AddEditV2Component", () => {
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
});
describe("reloadAddEditCipherData", () => {
beforeEach(fakeAsync(() => {
addEditCipherInfo$.next({
cipher: {
name: "InitialName",
type: CipherType.Login,
login: {
password: "initialPassword",
username: "initialUsername",
uris: [{ uri: "https://initial.com" }],
},
},
} as AddEditCipherInfo);
queryParams$.next({});
tick();
cipherServiceMock.setAddEditCipherInfo.mockClear();
}));
it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
const newCipherInfo = {
cipher: {
name: "UpdatedName",
type: CipherType.Login,
login: {
password: "updatedPassword",
uris: [{ uri: "https://updated.com" }],
},
},
} as AddEditCipherInfo;
addEditCipherInfo$.next(newCipherInfo);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "UpdatedName",
password: "updatedPassword",
loginUri: "https://updated.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
}));
it("does not reload data if config is not set", fakeAsync(() => {
component.config = null;
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
addEditCipherInfo$.next(null);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "InitialName",
password: "initialPassword",
username: "initialUsername",
loginUri: "https://initial.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("ignores messages with different commands", fakeAsync(() => {
const initialValues = component.config.initialValues;
const messageListener = component["messageListener"];
messageListener({ command: "someOtherCommand" });
tick();
expect(component.config.initialValues).toBe(initialValues);
}));
});
});

View File

@@ -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 } from "@angular/core";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router";
@@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
IconButtonModule,
],
})
export class AddEditV2Component implements OnInit {
export class AddEditV2Component implements OnInit, OnDestroy {
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable<boolean>;
@@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit {
this.subscribeToParams();
}
private messageListener: (message: any) => void;
async ngOnInit() {
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.enable();
}
// Listen for messages to reload cipher data when the pop up is already open
this.messageListener = async (message: any) => {
if (message?.command === "reloadAddEditCipherData") {
try {
await this.reloadCipherData();
} catch (error) {
this.logService.error("Failed to reload cipher data", error);
}
}
};
BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener);
}
ngOnDestroy() {
if (this.messageListener) {
BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener);
}
}
/**
* Reloads the cipher data when the popup is already open and new form data is submitted.
* This completely replaces the initialValues to clear any stale data from the previous submission.
*/
private async reloadCipherData() {
if (!this.config) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const latestCipherInfo = await firstValueFrom(
this.cipherService.addEditCipherInfo$(activeUserId),
);
if (latestCipherInfo != null) {
this.config = {
...this.config,
initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo),
};
// Be sure to clear the "cached" cipher info, so it doesn't get used again
await this.cipherService.setAddEditCipherInfo(null, activeUserId);
}
}
/**

View File

@@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => {
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{
provide: CipherArchiveService,
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) },
},
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },

View File

@@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent {
}),
);
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$;
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,

View File

@@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$);
protected readonly userHasArchivedItems = toSignal(
this.userId$.pipe(

View File

@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import {
@@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => {
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
.mockImplementation();
beforeEach(() => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue();
global.chrome = {
...global.chrome,
runtime: {
...global.chrome?.runtime,
sendMessage: jest.fn().mockResolvedValue(undefined),
getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`),
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => {
},
);
});
it("sends a message to refresh data when the popup is already open", async () => {
const existingPopupTab = {
id: 123,
windowId: 456,
url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`,
} as chrome.tabs.Tab;
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]);
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties");
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
{
cipherType: CipherType.Login,
},
);
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith({
command: "reloadAddEditCipherData",
data: { cipherId: undefined, cipherType: CipherType.Login },
});
expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true });
});
});
describe("closeAddEditVaultItemPopout", () => {

View File

@@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout(
addEditCipherUrl += formatQueryString("uri", url);
}
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
const existingPopup = existingPopupTabs.find((tab) =>
tab.url?.includes(`singleActionPopout=${singleActionKey}`),
);
// Check if the an existing popup is already open
try {
await chrome.runtime.sendMessage({
command: "reloadAddEditCipherData",
data: { cipherId, cipherType },
});
await BrowserApi.updateWindowProperties(existingPopup.windowId, {
focused: true,
});
} catch {
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
}
}
/**

View File

@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
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 { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -81,6 +82,7 @@ export class LoginCommand {
protected ssoUrlService: SsoUrlService,
protected i18nService: I18nService,
protected masterPasswordService: MasterPasswordServiceAbstraction,
protected encryptedMigrator: EncryptedMigrator,
) {}
async run(email: string, password: string, options: OptionValues) {
@@ -367,6 +369,8 @@ export class LoginCommand {
}
}
await this.encryptedMigrator.runMigrations(response.userId, password);
return await this.handleSuccessResponse(response);
} catch (e) {
if (

View File

@@ -182,6 +182,7 @@ export abstract class BaseProgram {
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
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";
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
const logout = jest.fn();
const i18nService = mock<I18nService>();
const encryptedMigrator = mock<EncryptedMigrator>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const configService = mock<ConfigService>();
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
organizationApiService,
logout,
i18nService,
encryptedMigrator,
masterPasswordUnlockService,
configService,
);

View File

@@ -9,6 +9,7 @@ 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";
@@ -38,6 +39,7 @@ export class UnlockCommand {
private organizationApiService: OrganizationApiServiceAbstraction,
private logout: () => Promise<void>,
private i18nService: I18nService,
private encryptedMigrator: EncryptedMigrator,
private masterPasswordUnlockService: MasterPasswordUnlockService,
private configService: ConfigService,
) {}
@@ -116,6 +118,8 @@ export class UnlockCommand {
}
}
await this.encryptedMigrator.runMigrations(userId, password);
return this.successResponse();
}

View File

@@ -176,6 +176,7 @@ export class OssServeConfigurator {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
this.serviceContainer.ssoUrlService,
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.encryptedMigrator,
);
const response = await command.run(email, password, options);
this.processResponse(response, true);
@@ -311,6 +312,7 @@ export class Program extends BaseProgram {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -76,6 +76,10 @@ import {
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/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";
@@ -324,6 +328,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
encryptedMigrator: EncryptedMigrator;
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
@@ -975,6 +980,16 @@ export class ServiceContainer {
);
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
this.encryptedMigrator = new DefaultEncryptedMigrator(
this.kdfConfigService,
changeKdfService,
this.logService,
this.configService,
this.masterPasswordService,
this.syncService,
);
}
async logout() {

View File

@@ -11,4 +11,4 @@ if (isRelease) {
process.env.RUST_LOG = 'debug';
}
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });
execSync(`napi build --platform --js false ${isRelease ? '--release' : ''}`, { stdio: 'inherit', env: process.env });

View File

@@ -32,8 +32,9 @@
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>

View File

@@ -1093,6 +1093,24 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"featureUnavailable": {
"message": "Feature unavailable"
},

View File

@@ -314,6 +314,7 @@ export class NativeMessagingMain {
"Microsoft Edge Canary": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Canary/`,
Vivaldi: `${this.homedir()}/Library/Application\ Support/Vivaldi/`,
Zen: `${this.homedir()}/Library/Application\ Support/Zen/`,
Helium: `${this.homedir()}/Library/Application\ Support/net.imput.helium/`,
};
/* eslint-enable no-useless-escape */
}

View File

@@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
switchMap((id) =>
combineLatest([
this.cipherArchiveService.userCanArchive$(id),
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
),
),

View File

@@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
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 { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -59,6 +60,7 @@ export class VaultFilterComponent
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
vaultFilterService,
@@ -72,6 +74,7 @@ export class VaultFilterComponent
restrictedItemTypesService,
cipherService,
cipherArchiveService,
premiumUpgradePromptService,
);
}

View File

@@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit {
message: this.i18nService.t("twoStepRecoverDisabled"),
});
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) {

View File

@@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@@ -203,10 +203,22 @@
{{ "eventLogs" | i18n }}
</button>
@if (showArchiveButton) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
@if (userCanArchive) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
@if (!userCanArchive) {
<button bitMenuItem (click)="badge.promptForPremium($event)" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
<!-- Hide app-premium badge from accessibility tools as it results in a button within a button -->
<div slot="end" class="-tw-mt-0.5" aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</button>
}
}
@if (showUnArchiveButton) {

View File

@@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => {
fixture = TestBed.createComponent(VaultCipherRowComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("archiveEnabled", false);
overlayContainer = TestBed.inject(OverlayContainer);
});

View File

@@ -8,6 +8,7 @@ import {
OnInit,
Output,
ViewChild,
input,
} from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
@@ -101,8 +102,10 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() userCanArchive: boolean;
/** Archive feature is enabled */
readonly archiveEnabled = input.required<boolean>();
/**
* Enforge Org Data Ownership Policy Status
* Enforce Org Data Ownership Policy Status
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -142,16 +145,21 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
protected get showArchiveButton() {
if (!this.archiveEnabled()) {
return false;
}
return (
this.userCanArchive &&
!CipherViewLikeUtils.isArchived(this.cipher) &&
!CipherViewLikeUtils.isDeleted(this.cipher) &&
!this.cipher.organizationId
!CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher)
);
}
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
if (!this.archiveEnabled()) {
return false;
}
return CipherViewLikeUtils.isArchived(this.cipher);
}

View File

@@ -179,6 +179,7 @@
(onEvent)="event($event)"
[userCanArchive]="userCanArchive"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
[archiveEnabled]="archiveFeatureEnabled$ | async"
></tr>
</ng-container>
</ng-template>

View File

@@ -4,6 +4,7 @@ import { of } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -54,6 +55,12 @@ describe("VaultItemsComponent", () => {
t: (key: string) => key,
},
},
{
provide: CipherArchiveService,
useValue: {
hasArchiveFlagEnabled$: of(true),
},
},
],
});

View File

@@ -7,6 +7,7 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
RestrictedCipherType,
@@ -145,9 +146,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
protected disableMenu$: Observable<boolean>;
private restrictedTypes: RestrictedCipherType[] = [];
protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$;
constructor(
protected cipherAuthorizationService: CipherAuthorizationService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherArchiveService: CipherArchiveService,
) {
this.canDeleteSelected$ = this.selection.changed.pipe(
startWith(null),

View File

@@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
@@ -29,6 +30,7 @@ import { VaultItemsComponent } from "./vault-items.component";
PipesModule,
CopyCipherFieldDirective,
ScrollLayoutDirective,
PremiumBadgeComponent,
],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
exports: [VaultItemsComponent],

View File

@@ -30,6 +30,7 @@ import {
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -143,6 +144,12 @@ export default {
isCipherRestricted: () => false, // No restrictions for this story
},
},
{
provide: CipherArchiveService,
useValue: {
hasArchiveFlagEnabled$: of(true),
},
},
],
}),
applicationConfig({

View File

@@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -170,6 +172,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit(): Promise<void> {
@@ -252,14 +255,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
};
async buildAllFilters(): Promise<VaultFilterList> {
const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$());
const [userId, showArchive] = await firstValueFrom(
combineLatest([
this.accountService.activeAccount$.pipe(getUserId),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
);
const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter();
builderFilter.typeFilter = await this.addTypeFilter();
builderFilter.folderFilter = await this.addFolderFilter();
builderFilter.collectionFilter = await this.addCollectionFilter();
if (hasArchiveFlag) {
builderFilter.archiveFilter = await this.addArchiveFilter();
if (showArchive) {
builderFilter.archiveFilter = await this.addArchiveFilter(userId);
}
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
@@ -419,7 +428,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
return trashFilterSection;
}
protected async addArchiveFilter(): Promise<VaultFilterSection> {
protected async addArchiveFilter(userId: UserId): Promise<VaultFilterSection> {
const [hasArchivedCiphers, userHasPremium] = await firstValueFrom(
combineLatest([
this.cipherArchiveService
.archivedCiphers$(userId)
.pipe(map((archivedCiphers) => archivedCiphers.length > 0)),
this.cipherArchiveService.userHasPremium$(userId),
]),
);
const promptForPremiumOnFilter = !userHasPremium && !hasArchivedCiphers;
const archiveFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{
@@ -442,6 +462,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
isSelectable: true,
},
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
premiumOptions: {
showBadgeForNonPremium: true,
blockFilterAction: promptForPremiumOnFilter
? async () => await this.premiumUpgradePromptService.promptForPremium()
: undefined,
},
};
return archiveFilterSection;
}

View File

@@ -105,6 +105,9 @@
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</ng-container>
<ng-container *ngIf="premiumFeature">
<app-premium-badge></app-premium-badge>
</ng-container>
</span>
</span>
<ul

View File

@@ -96,6 +96,11 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
}
async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
if (this.section?.premiumOptions?.blockFilterAction) {
await this.section.premiumOptions.blockFilterAction();
return;
}
await this.section?.action(filterNode);
}
@@ -123,6 +128,10 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
return this.section?.options;
}
get premiumFeature() {
return this.section?.premiumOptions?.showBadgeForNonPremium;
}
get divider() {
return this.section?.divider;
}

View File

@@ -47,6 +47,16 @@ export type VaultFilterSection = {
component: any;
};
divider?: boolean;
premiumOptions?: {
/** When true, the premium badge will show on the filter for non-premium users. */
showBadgeForNonPremium?: true;
/**
* Action to be called instead of applying the filter.
* Useful when the user does not have access to a filter (e.g., premium feature)
* and custom behavior is needed when invoking the filter.
*/
blockFilterAction?: () => Promise<void>;
};
};
export type VaultFilterList = {

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { SearchModule } from "@bitwarden/components";
import { SharedModule } from "../../../../shared";
@@ -7,7 +8,7 @@ import { SharedModule } from "../../../../shared";
import { VaultFilterSectionComponent } from "./components/vault-filter-section.component";
@NgModule({
imports: [SharedModule, SearchModule],
imports: [SharedModule, SearchModule, PremiumBadgeComponent],
declarations: [VaultFilterSectionComponent],
exports: [SharedModule, VaultFilterSectionComponent, SearchModule],
})

View File

@@ -34,6 +34,16 @@
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<bit-callout
type="info"
[title]="'premiumSubscriptionEnded' | i18n"
*ngIf="showSubscriptionEndedMessaging$ | async"
>
<p>{{ "premiumSubscriptionEndedDesc" | i18n }}</p>
<a routerLink="/settings/subscription/premium" bitButton buttonType="primary">{{
"restartPremium" | i18n
}}</a>
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"

View File

@@ -84,7 +84,7 @@ import {
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
@@ -177,6 +177,7 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultItemsModule,
SharedModule,
OrganizationWarningsModule,
BannerComponent,
],
providers: [
RoutedVaultFilterService,
@@ -230,13 +231,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
emptyState$ = combineLatest([
this.currentSearchText$,
this.routedVaultFilterService.filter$,
@@ -295,14 +289,28 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}),
);
protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected enforceOrgDataOwnershipPolicy$ = this.userId$.pipe(
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected userCanArchive$ = this.userId$.pipe(
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
switchMap((userId) =>
combineLatest([
this.routedVaultFilterBridgeService.activeFilter$,
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
),
);
constructor(
private syncService: SyncService,
@@ -438,13 +446,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
allowedCiphers$,
filter$,
this.currentSearchText$,
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
concatMap(async ([ciphers, filter, searchText, showArchiveVault]) => {
const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter, archiveEnabled);
const filterFunction = createFilterFunction(filter, showArchiveVault);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];

View File

@@ -3133,6 +3133,15 @@
}
}
},
"premiumSubscriptionEnded": {
"message": "Your Premium subscription ended"
},
"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."
},
"restartPremium": {
"message": "Restart Premium"
},
"additionalStorageGb": {
"message": "Additional storage (GB)"
},
@@ -4621,6 +4630,24 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"deleteRecoverDesc": {
"message": "Enter your email address below to recover and delete your account."
},