1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 00:23:17 +00:00

Merge branch 'main' into PM-19741

This commit is contained in:
Miles Blackwood
2025-05-12 19:21:11 -04:00
50 changed files with 2449 additions and 366 deletions

View File

@@ -312,6 +312,7 @@
"@angular/platform-browser",
"@angular/platform",
"@angular/router",
"axe-playwright",
"@compodoc/compodoc",
"@ng-select/ng-select",
"@storybook/addon-a11y",
@@ -320,6 +321,7 @@
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-links",
"@storybook/test-runner",
"@storybook/addon-themes",
"@storybook/angular",
"@storybook/manager-api",

View File

@@ -26,6 +26,9 @@ const preview: Preview = {
wrapperDecorator,
],
parameters: {
a11y: {
element: "#storybook-root",
},
controls: {
matchers: {
color: /(background|color)$/i,

47
.storybook/test-runner.ts Normal file
View File

@@ -0,0 +1,47 @@
import { type TestRunnerConfig } from "@storybook/test-runner";
import { injectAxe, checkA11y } from "axe-playwright";
const testRunnerConfig: TestRunnerConfig = {
setup() {},
async preVisit(page, context) {
return await injectAxe(page);
},
async postVisit(page, context) {
await page.waitForSelector("#storybook-root");
// https://github.com/abhinaba-ghosh/axe-playwright#parameters-on-checka11y-axerun
await checkA11y(
// Playwright page instance.
page,
// context
"#storybook-root",
// axeOptions, see https://www.deque.com/axe/core-documentation/api-documentation/#parameters-axerun
{
detailedReport: true,
detailedReportOptions: {
// Includes the full html for invalid nodes
html: true,
},
verbose: false,
},
// skipFailures
false,
// reporter "v2" is terminal reporter, "html" writes results to file
"v2",
// axeHtmlReporterOptions
// NOTE: set reporter param (above) to "html" to activate these options
{
outputDir: "reports/a11y",
reportFileName: `${context.id}.html`,
},
);
},
};
export default testRunnerConfig;

View File

@@ -1088,22 +1088,13 @@
}
}
},
"loginSaveConfirmation": {
"message": "$ITEMNAME$ saved to Bitwarden.",
"placeholders": {
"itemName": {
"content": "$1"
}
},
"notificationLoginSaveConfirmation": {
"message": "saved to Bitwarden.",
"description": "Shown to user after item is saved."
},
"loginUpdatedConfirmation": {
"message": "$ITEMNAME$ updated in Bitwarden.",
"placeholders": {
"itemName": {
"content": "$1"
}
},
"notificationLoginUpdatedConfirmation": {
"message": "updated in Bitwarden.",
"description": "Shown to user after item is updated."
},
"saveAsNewLoginAction": {

View File

@@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
@@ -828,6 +829,7 @@ describe("NotificationBackground", () => {
id: "testId",
name: "testItemName",
login: { username: "testUser" },
reprompt: CipherRepromptType.None,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -842,6 +844,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
"testId",
false,
);
expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
@@ -855,6 +858,55 @@ describe("NotificationBackground", () => {
);
});
it("prompts the user for master password entry if the notification message type is for ChangePassword and the cipher reprompt is enabled", async () => {
const tab = createChromeTabMock({ id: 1, url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab });
const message: NotificationBackgroundExtensionMessage = {
command: "bgSaveCipher",
edit: false,
folder: "folder-id",
};
const queueMessage = mock<AddChangePasswordQueueMessage>({
type: NotificationQueueMessageType.ChangePassword,
tab,
domain: "example.com",
newPassword: "newPassword",
});
notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>({
id: "testId",
name: "testItemName",
login: { username: "testUser" },
reprompt: CipherRepromptType.Password,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(editItemSpy).not.toHaveBeenCalled();
expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalled();
expect(createWithServerSpy).not.toHaveBeenCalled();
expect(updatePasswordSpy).toHaveBeenCalledWith(
cipherView,
queueMessage.newPassword,
message.edit,
sender.tab,
"testId",
false,
);
expect(updateWithServerSpy).not.toHaveBeenCalled();
expect(tabSendMessageDataSpy).not.toHaveBeenCalledWith(
sender.tab,
"saveCipherAttemptCompleted",
{
itemName: "testItemName",
cipherId: cipherView.id,
task: undefined,
},
);
});
it("completes password update notification with a security task notice if any are present for the cipher, and dismisses tasks for the updated cipher", async () => {
const mockCipherId = "testId";
const mockOrgId = "testOrgId";
@@ -905,6 +957,7 @@ describe("NotificationBackground", () => {
id: mockCipherId,
organizationId: mockOrgId,
name: "Test Item",
reprompt: CipherRepromptType.None,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -919,6 +972,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
mockCipherId,
false,
);
expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
@@ -1000,6 +1054,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
"testId",
false,
);
expect(editItemSpy).toHaveBeenCalled();
expect(updateWithServerSpy).not.toHaveBeenCalled();
@@ -1170,7 +1225,7 @@ describe("NotificationBackground", () => {
newPassword: "newPassword",
});
notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>();
const cipherView = mock<CipherView>({ reprompt: CipherRepromptType.None });
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
const errorMessage = "fetch error";
updateWithServerSpy.mockImplementation(() => {

View File

@@ -17,6 +17,7 @@ import {
ExtensionCommand,
ExtensionCommandType,
NOTIFICATION_BAR_LIFESPAN_MS,
UPDATE_PASSWORD,
} from "@bitwarden/common/autofill/constants";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
@@ -110,6 +111,8 @@ export default class NotificationBackground {
this.removeTabFromNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgHandleReprompt: ({ message, sender }: any) =>
this.handleCipherUpdateRepromptResponse(message),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
collectPageDetailsResponse: ({ message }) =>
@@ -683,6 +686,17 @@ export default class NotificationBackground {
await this.saveOrUpdateCredentials(sender.tab, message.edit, message.folder);
}
async handleCipherUpdateRepromptResponse(message: NotificationBackgroundExtensionMessage) {
if (message.success) {
await this.saveOrUpdateCredentials(message.tab, false, undefined, true);
} else {
await BrowserApi.tabSendMessageData(message.tab, "saveCipherAttemptCompleted", {
error: "Password reprompt failed",
});
return;
}
}
/**
* Saves or updates credentials based on the message within the
* notification queue that is associated with the specified tab.
@@ -691,7 +705,12 @@ export default class NotificationBackground {
* @param edit - Identifies if the credentials should be edited or simply added
* @param folderId - The folder to add the cipher to
*/
private async saveOrUpdateCredentials(tab: chrome.tabs.Tab, edit: boolean, folderId?: string) {
private async saveOrUpdateCredentials(
tab: chrome.tabs.Tab,
edit: boolean,
folderId?: string,
skipReprompt: boolean = false,
) {
for (let i = this.notificationQueue.length - 1; i >= 0; i--) {
const queueMessage = this.notificationQueue[i];
if (
@@ -706,18 +725,26 @@ export default class NotificationBackground {
continue;
}
this.notificationQueue.splice(i, 1);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (queueMessage.type === NotificationQueueMessageType.ChangePassword) {
const cipherView = await this.getDecryptedCipherById(queueMessage.cipherId, activeUserId);
await this.updatePassword(cipherView, queueMessage.newPassword, edit, tab, activeUserId);
await this.updatePassword(
cipherView,
queueMessage.newPassword,
edit,
tab,
activeUserId,
skipReprompt,
);
return;
}
this.notificationQueue.splice(i, 1);
// If the vault was locked, check if a cipher needs updating instead of creating a new one
if (queueMessage.wasVaultLocked) {
const allCiphers = await this.cipherService.getAllDecryptedForUrl(
@@ -777,6 +804,7 @@ export default class NotificationBackground {
edit: boolean,
tab: chrome.tabs.Tab,
userId: UserId,
skipReprompt: boolean = false,
) {
cipherView.login.password = newPassword;
@@ -810,6 +838,12 @@ export default class NotificationBackground {
}
: undefined;
if (cipherView.reprompt && !skipReprompt) {
await this.autofillService.isPasswordRepromptRequired(cipherView, tab, UPDATE_PASSWORD);
return;
}
await this.cipherService.updateWithServer(cipher);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {

View File

@@ -107,9 +107,9 @@ export const mockI18n = {
collection: "Collection",
folder: "Folder",
loginSaveSuccess: "Login saved",
loginSaveConfirmation: "$ITEMNAME$ saved to Bitwarden.",
notificationLoginSaveConfirmation: "saved to Bitwarden.",
loginUpdateSuccess: "Login updated",
loginUpdatedConfirmation: "$ITEMNAME$ updated in Bitwarden.",
notificationLoginUpdatedConfirmation: "updated in Bitwarden.",
loginUpdateTaskSuccess:
"Great job! You took the steps to make you and $ORGANIZATION$ more secure.",
loginUpdateTaskSuccessAdditional:

View File

@@ -94,7 +94,7 @@ const notificationContainerStyles = (theme: Theme) => css`
}
[class*="${notificationBodyClassPrefix}-"] {
margin: ${spacing["3"]} 0 ${spacing["1.5"]} ${spacing["3"]};
margin: ${spacing["3"]} 0 0 ${spacing["3"]};
padding-right: ${spacing["3"]};
}
`;

View File

@@ -8,7 +8,7 @@ import {
NotificationTypes,
} from "../../../notification/abstractions/notification-bar";
import { OrgView, FolderView, I18n, CollectionView } from "../common-types";
import { spacing, themes } from "../constants/styles";
import { spacing } from "../constants/styles";
import { NotificationButtonRow } from "./button-row";
@@ -38,7 +38,7 @@ export function NotificationFooter({
const primaryButtonText = i18n.saveAction;
return html`
<div class=${[displayFlex, notificationFooterStyles({ theme })]}>
<div class=${[displayFlex, notificationFooterStyles({ isChangeNotification })]}>
${!isChangeNotification
? NotificationButtonRow({
collections,
@@ -61,12 +61,15 @@ export const displayFlex = css`
display: flex;
`;
export const notificationFooterStyles = ({ theme }: { theme: Theme }) => css`
background-color: ${themes[theme].background.alt};
padding: 0 ${spacing[3]} ${spacing[3]} ${spacing[3]};
const notificationFooterStyles = ({
isChangeNotification,
}: {
isChangeNotification: boolean;
}) => css`
padding: ${spacing[2]} ${spacing[4]} ${isChangeNotification ? spacing[1] : spacing[4]}
${spacing[4]};
:last-child {
border-radius: 0 0 ${spacing["4"]} ${spacing["4"]};
padding-bottom: ${spacing[4]};
}
`;

View File

@@ -62,7 +62,7 @@ const buttonRowStyles = css`
> button {
max-width: min-content;
flex: 1 1 50%;
flex: 1 1 25%;
}
> div {

View File

@@ -77,6 +77,10 @@ function getI18n() {
notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
notificationEdit: chrome.i18n.getMessage("edit"),
notificationEditTooltip: chrome.i18n.getMessage("notificationEditTooltip"),
notificationLoginSaveConfirmation: chrome.i18n.getMessage("notificationLoginSaveConfirmation"),
notificationLoginUpdatedConfirmation: chrome.i18n.getMessage(
"notificationLoginUpdatedConfirmation",
),
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),

View File

@@ -15,6 +15,7 @@ export type NotificationsExtensionMessage = {
typeData?: NotificationTypeData;
height?: number;
error?: string;
closedByUser?: boolean;
fadeOutNotification?: boolean;
params: object;
};

View File

@@ -111,13 +111,15 @@ export class OverlayNotificationsContentService
* @param message - The message containing the data for closing the notification bar.
*/
private handleCloseNotificationBarMessage(message: NotificationsExtensionMessage) {
const closedByUser =
typeof message.data?.closedByUser === "boolean" ? message.data.closedByUser : true;
if (message.data?.fadeOutNotification) {
setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true);
globalThis.setTimeout(() => this.closeNotificationBar(true), 150);
globalThis.setTimeout(() => this.closeNotificationBar(closedByUser), 150);
return;
}
this.closeNotificationBar(true);
this.closeNotificationBar(closedByUser);
}
/**

View File

@@ -87,5 +87,9 @@ export abstract class AutofillService {
cipherType?: CipherType,
) => Promise<string | null>;
setAutoFillOnPageLoadOrgPolicy: () => Promise<void>;
isPasswordRepromptRequired: (cipher: CipherView, tab: chrome.tabs.Tab) => Promise<boolean>;
isPasswordRepromptRequired: (
cipher: CipherView,
tab: chrome.tabs.Tab,
action?: string,
) => Promise<boolean>;
}

View File

@@ -593,15 +593,20 @@ export default class AutofillService implements AutofillServiceInterface {
*
* @param cipher - The cipher to autofill
* @param tab - The tab to autofill
* @param action - override for default action once reprompt is completed successfully
*/
async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
async isPasswordRepromptRequired(
cipher: CipherView,
tab: chrome.tabs.Tab,
action?: string,
): Promise<boolean> {
const userHasMasterPasswordAndKeyHash =
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) {
if (!this.isDebouncingPasswordRepromptPopout()) {
await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: "autofill",
action: action ?? "autofill",
});
}

View File

@@ -664,6 +664,10 @@ export class BrowserApi {
* Identifies if the browser autofill settings are overridden by the extension.
*/
static async browserAutofillSettingsOverridden(): Promise<boolean> {
if (!(await BrowserApi.permissionsGranted(["privacy"]))) {
return false;
}
const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResult<boolean>) =>
details.levelOfControl === "controlled_by_this_extension" && !details.value;

View File

@@ -19,7 +19,7 @@ import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "@bitwarden/angular/platform/abstractions/view-cache.service";
} from "@bitwarden/angular/platform/view-cache";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";

View File

@@ -5,9 +5,9 @@ import { Router } from "@angular/router";
import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,

View File

@@ -19,6 +19,7 @@ import {
COPY_USERNAME_ID,
COPY_VERIFICATION_CODE_ID,
SHOW_AUTOFILL_BUTTON,
UPDATE_PASSWORD,
} from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -49,6 +50,7 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { sendExtensionMessage } from "../../../../../autofill/utils/index";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
@@ -72,7 +74,8 @@ type LoadAction =
| typeof SHOW_AUTOFILL_BUTTON
| typeof COPY_USERNAME_ID
| typeof COPY_PASSWORD_ID
| typeof COPY_VERIFICATION_CODE_ID;
| typeof COPY_VERIFICATION_CODE_ID
| typeof UPDATE_PASSWORD;
@Component({
selector: "app-view-v2",
@@ -294,7 +297,7 @@ export class ViewV2Component {
// Both vaultPopupAutofillService and copyCipherFieldService will perform password re-prompting internally.
switch (loadAction) {
case "show-autofill-button":
case SHOW_AUTOFILL_BUTTON:
// This action simply shows the cipher view, no need to do anything.
if (
this.cipher.reprompt !== CipherRepromptType.None &&
@@ -303,30 +306,42 @@ export class ViewV2Component {
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
}
return;
case "autofill":
case AUTOFILL_ID:
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
break;
case "copy-username":
case COPY_USERNAME_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.username,
"username",
this.cipher,
);
break;
case "copy-password":
case COPY_PASSWORD_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.password,
"password",
this.cipher,
);
break;
case "copy-totp":
case COPY_VERIFICATION_CODE_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.totp,
"totp",
this.cipher,
);
break;
case UPDATE_PASSWORD: {
const repromptSuccess = await this.passwordRepromptService.showPasswordPrompt();
await sendExtensionMessage("bgHandleReprompt", {
tab: await chrome.tabs.get(senderTabId),
success: repromptSuccess,
});
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
break;
}
}
if (BrowserPopupUtils.inPopout(window)) {

View File

@@ -4,6 +4,7 @@ import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -20,8 +21,6 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import {
CachedFilterState,
MY_VAULT_ID,
@@ -123,7 +122,7 @@ describe("VaultPopupListFiltersService", () => {
useValue: accountService,
},
{
provide: PopupViewCacheService,
provide: ViewCacheService,
useValue: viewCacheService,
},
],

View File

@@ -15,6 +15,7 @@ import {
} from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -40,8 +41,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { ChipSelectOption } from "@bitwarden/components";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
deserializer: (obj) => obj,
});
@@ -178,7 +177,7 @@ export class VaultPopupListFiltersService {
private policyService: PolicyService,
private stateProvider: StateProvider,
private accountService: AccountService,
private viewCacheService: PopupViewCacheService,
private viewCacheService: ViewCacheService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())

View File

@@ -64,7 +64,7 @@
]
},
"dependencies": {
"@koa/multer": "3.0.2",
"@koa/multer": "3.1.0",
"@koa/router": "13.1.0",
"argon2": "0.41.1",
"big-integer": "1.6.52",
@@ -81,7 +81,7 @@
"koa-json": "2.0.2",
"lowdb": "1.0.0",
"lunr": "2.3.9",
"multer": "1.4.5-lts.1",
"multer": "1.4.5-lts.2",
"node-fetch": "2.6.12",
"node-forge": "1.3.1",
"open": "8.4.2",

View File

@@ -112,13 +112,15 @@ export class SponsoredFamiliesComponent implements OnInit, OnDestroy {
});
}
});
this.anyOrgsAvailable$ = this.availableSponsorshipOrgs$.pipe(map((orgs) => orgs.length > 0));
this.activeSponsorshipOrgs$ = this.organizationService
.organizations$(userId)
.pipe(map((orgs) => orgs.filter((o) => o.familySponsorshipFriendlyName !== null)));
.pipe(
map((orgs) =>
orgs.filter((o) => o.familySponsorshipFriendlyName !== null && !o.isAdminInitiated),
),
);
this.anyActiveSponsorships$ = this.activeSponsorshipOrgs$.pipe(map((orgs) => orgs.length > 0));
this.loading = false;

View File

@@ -0,0 +1 @@
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";

View File

@@ -0,0 +1 @@
export { NoopViewCacheService } from "./noop-view-cache.service";

View File

@@ -1,11 +1,7 @@
import { Injectable, signal, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "../abstractions/view-cache.service";
import { FormCacheOptions, SignalCacheOptions, ViewCacheService } from "./view-cache.service";
/**
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,

View File

@@ -0,0 +1,130 @@
# Extension Persistence
By default, when the browser extension popup closes, the user's current view and any data entered
without saving is lost. This introduces friction in several workflows within our client, such as:
- Performing actions that require email OTP entry, since the user must navigate from the popup to
get to their email inbox
- Entering information to create a new vault item from a browser tab
- And many more
Previously, we have recommended that users "pop out" the extension into its own window to persist
the extension context, but this introduces additional user actions and may leave the extension open
(and unlocked) for longer than a user intends.
In order to provide a better user experience, we have introduced two levels of persistence to the
Bitwarden extension client:
- We persist the route history, allowing us to re-open the last route when the popup re-opens, and
- We offer a service for teams to use to persist component-specific form data or state to survive a
popup close/re-open cycle
## Persistence lifetime
Since we are persisting data, it is important that the lifetime of that data be well-understood and
well-constrained. The cache of route history and form data is cleared when any of the following
events occur:
- The account is locked
- The account is logged out
- Account switching is used to switch the active account
- The extension popup has been closed for 2 minutes
In addition, cached form data is cleared when a browser extension navigation event occurs (e.g.
switching between tabs in the extension).
## Types of persistence
### Route history persistence
Route history is persisted on the extension automatically, with no specific implementation required
on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
### View data persistence
Route persistence ensures that the user will land back on the route that they were on when the popup
closed, but it does not persist any state or form data that the user may have modified. In order to
persist that data, the component is responsible for registering that data with the
[`ViewCacheService`](./view-cache.service.ts).
This is done prescriptively to ensure that only necessary data is cached and that it is done with
intention by the component.
The `ViewCacheService` provides an interface for caching both individual state and `FormGroup`s.
#### Caching individual data elements
For individual pieces of state, use the `signal()` method on the `ViewCacheService` to create a
writeable [signal](https://angular.dev/guide/signals) wrapper around the desired state.
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
```
If a cached value exists, the returned signal will contain the cached data.
Setting the value should be done through the signal's `set()` method:
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
Putting this together, the most common implementation pattern would be:
1. **Register the signal** using `ViewCacheService.signal()` on initialization of the component or
service responsible for the state being persisted.
2. **Restore state from the signal:** If cached data exists, the signal will contain that data. The
component or service should use this data to re-create the state from prior to the popup closing.
3. **Set new state** in the cache when it changes. Ensure that any updates to the data are persisted
to the cache with `set()`, so that the cache reflects the latest state.
#### Caching form data
For persisting form data, the `ViewCacheService` supplies a `formGroup()` method, which manages the
persistence of any entered form data to the cache and the initialization of the form from the cached
data. You can supply the `FormGroup` in the `control` parameter of the method, and the
`ViewCacheService` will:
- Initialize the form the a cached value, if it exists
- Save form value to cache when it changes
- Mark the form dirty if the restored value is not `undefined`.
```typescript
this.loginDetailsForm = this.viewCacheService.formGroup({
key: "my-form",
control: this.formBuilder.group({
username: [""],
email: [""],
}),
});
```
## What about other clients?
The `ViewCacheService` is designed to be injected into shared, client-agnostic components. A
`NoopViewCacheService` is provided and injected for non-extension clients, preserving a single
interface for your components.

View File

@@ -42,6 +42,8 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
/**
* Cache for temporary component state
*
* [Read more](./view-cache.md)
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close
* - all other clients: noop

View File

@@ -325,13 +325,14 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import { ViewCacheService } from "../platform/view-cache";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,

View File

@@ -23,12 +23,10 @@ import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request"
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -101,7 +99,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private validationService: ValidationService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private loginViaAuthRequestCacheService: LoginViaAuthRequestCacheService,
private configService: ConfigService,
) {
this.clientType = this.platformUtilsService.getClientType();
@@ -132,7 +129,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
async ngOnInit(): Promise<void> {
// Get the authStatus early because we use it in both flows
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
await this.loginViaAuthRequestCacheService.init();
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
@@ -410,24 +406,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(authRequest);
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
if (!this.authRequestKeyPair.privateKey) {
this.logService.error("No private key when trying to cache the login view.");
return;
}
if (!this.accessCode) {
this.logService.error("No access code when trying to cache the login view.");
return;
}
this.loginViaAuthRequestCacheService.cacheLoginView(
authRequestResponse.id,
this.authRequestKeyPair.privateKey,
this.accessCode,
);
if (!this.authRequestKeyPair.privateKey) {
this.logService.error("No private key when trying to cache the login view.");
return;
}
if (!this.accessCode) {
this.logService.error("No access code when trying to cache the login view.");
return;
}
this.loginViaAuthRequestCacheService.cacheLoginView(
authRequestResponse.id,
this.authRequestKeyPair.privateKey,
this.accessCode,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,7 +1,7 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,7 +1,7 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,9 +1,8 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
@@ -14,74 +13,40 @@ describe("LoginViaAuthRequestCache", () => {
const cacheSignal = signal<LoginViaAuthRequestView | null>(null);
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
const getFeatureFlag = jest.fn().mockResolvedValue(false);
const cacheSetMock = jest.spyOn(cacheSignal, "set");
beforeEach(() => {
getCacheSignal.mockClear();
getFeatureFlag.mockClear();
cacheSetMock.mockClear();
testBed = TestBed.configureTestingModule({
providers: [
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
LoginViaAuthRequestCacheService,
],
});
});
describe("feature enabled", () => {
beforeEach(() => {
getFeatureFlag.mockResolvedValue(true);
});
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
cacheSignal.set({ ...buildMockState() });
service = testBed.inject(LoginViaAuthRequestCacheService);
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
cacheSignal.set({ ...buildMockState() });
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
...buildMockState(),
});
});
it("updates the signal value", async () => {
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
const parameters = buildAuthenticMockAuthView();
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
expect(cacheSignal.set).toHaveBeenCalledWith({
id: parameters.id,
privateKey: Utils.fromBufferToB64(parameters.privateKey),
accessCode: parameters.accessCode,
});
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
...buildMockState(),
});
});
describe("feature disabled", () => {
beforeEach(async () => {
cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
getFeatureFlag.mockResolvedValue(false);
cacheSetMock.mockClear();
it("updates the signal value", async () => {
service = testBed.inject(LoginViaAuthRequestCacheService);
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
});
const parameters = buildAuthenticMockAuthView();
it("`getCachedCipherView` returns null", () => {
expect(service.getCachedLoginViaAuthRequestView()).toBeNull();
});
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
it("does not update the signal value", () => {
const params = buildAuthenticMockAuthView();
service.cacheLoginView(params.id, params.privateKey, params.accessCode);
expect(cacheSignal.set).not.toHaveBeenCalled();
expect(cacheSignal.set).toHaveBeenCalledWith({
id: parameters.id,
privateKey: Utils.fromBufferToB64(parameters.privateKey),
accessCode: parameters.accessCode,
});
});

View File

@@ -1,9 +1,7 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
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";
const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
@@ -17,10 +15,6 @@ const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
@Injectable()
export class LoginViaAuthRequestCacheService {
private viewCacheService: ViewCacheService = inject(ViewCacheService);
private configService: ConfigService = inject(ConfigService);
/** True when the `PM9112_DeviceApproval` flag is enabled */
private featureEnabled: boolean = false;
private defaultLoginViaAuthRequestCache: WritableSignal<LoginViaAuthRequestView | null> =
this.viewCacheService.signal<LoginViaAuthRequestView | null>({
@@ -31,23 +25,10 @@ export class LoginViaAuthRequestCacheService {
constructor() {}
/**
* Must be called once before interacting with the cached data, otherwise methods will be noop.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9112_DeviceApprovalPersistence,
);
}
/**
* Update the cache with the new LoginView.
*/
cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
if (!this.featureEnabled) {
return;
}
// When the keys get stored they should be converted to a B64 string to ensure
// data can be properly formed when json-ified. If not done, they are not stored properly and
// will not be parsable by the cryptography library after coming out of storage.
@@ -59,10 +40,6 @@ export class LoginViaAuthRequestCacheService {
}
clearCacheLoginView(): void {
if (!this.featureEnabled) {
return;
}
this.defaultLoginViaAuthRequestCache.set(null);
}
@@ -70,10 +47,6 @@ export class LoginViaAuthRequestCacheService {
* Returns the cached LoginViaAuthRequestView when available.
*/
getCachedLoginViaAuthRequestView(): LoginViaAuthRequestView | null {
if (!this.featureEnabled) {
return null;
}
return this.defaultLoginViaAuthRequestCache();
}
}

View File

@@ -59,6 +59,7 @@ describe("ORGANIZATIONS state", () => {
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -61,6 +61,7 @@ export class OrganizationData {
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -124,6 +125,7 @@ export class OrganizationData {
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isAdminInitiated = response.isAdminInitiated;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -91,6 +91,7 @@ export class Organization {
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -150,6 +151,7 @@ export class Organization {
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
this.isAdminInitiated = obj.isAdminInitiated;
}
get canAccess() {

View File

@@ -56,6 +56,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
constructor(response: any) {
super(response);
@@ -123,5 +124,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
}
}

View File

@@ -38,7 +38,7 @@ export const ClearClipboardDelay = {
FiveMinutes: 300,
} as const;
/* Context Menu item Ids */
/* Ids for context menu items and messaging events */
export const AUTOFILL_CARD_ID = "autofill-card";
export const AUTOFILL_ID = "autofill";
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
@@ -54,6 +54,7 @@ export const GENERATE_PASSWORD_ID = "generate-password";
export const NOOP_COMMAND_SUFFIX = "noop";
export const ROOT_ID = "root";
export const SEPARATOR_ID = "separator";
export const UPDATE_PASSWORD = "update-password";
export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds

View File

@@ -16,7 +16,6 @@ export enum FeatureFlag {
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
/* Autofill */
@@ -55,7 +54,6 @@ export enum FeatureFlag {
/* Vault */
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
@@ -107,14 +105,12 @@ export const DefaultFeatureFlagValue = {
/* Vault */
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
/* Billing */

View File

@@ -13,7 +13,7 @@ import {
import { BehaviorSubject } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock } from "jest-mock-extended";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";

View File

@@ -1,7 +1,7 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";

View File

@@ -1,6 +1,6 @@
import { inject, Injectable } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";

2227
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,8 @@
"storybook": "ng run components:storybook",
"build-storybook": "ng run components:build-storybook",
"build-storybook:ci": "ng run components:build-storybook --webpack-stats-json",
"test-stories": "test-storybook --url http://localhost:6006",
"test-stories:watch": "test-stories --watch",
"postinstall": "patch-package"
},
"workspaces": [
@@ -53,6 +55,7 @@
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/test-runner": "0.22.0",
"@storybook/addon-themes": "8.6.12",
"@storybook/angular": "8.6.12",
"@storybook/manager-api": "8.6.12",
@@ -85,6 +88,7 @@
"@yao-pkg/pkg": "5.16.1",
"angular-eslint": "18.4.3",
"autoprefixer": "10.4.21",
"axe-playwright": "2.1.0",
"babel-loader": "9.2.1",
"base64-loader": "1.0.0",
"browserslist": "4.23.2",
@@ -159,7 +163,7 @@
"@bitwarden/sdk-internal": "0.2.0-main.159",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "3.0.2",
"@koa/multer": "3.1.0",
"@koa/router": "13.1.0",
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
@@ -186,7 +190,7 @@
"lit": "3.2.1",
"lowdb": "1.0.0",
"lunr": "2.3.9",
"multer": "1.4.5-lts.1",
"multer": "1.4.5-lts.2",
"ngx-toastr": "19.0.0",
"node-fetch": "2.6.12",
"node-forge": "1.3.1",

View File

@@ -45,6 +45,7 @@
"files": [
".storybook/main.ts",
".storybook/manager.js",
".storybook/test-runner.ts",
"apps/browser/src/autofill/content/components/.lit-storybook/main.ts"
],
"include": ["apps/**/*", "libs/**/*", "bitwarden_license/**/*", "scripts/**/*"],