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:
2
.github/renovate.json5
vendored
2
.github/renovate.json5
vendored
@@ -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",
|
||||
|
||||
@@ -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
47
.storybook/test-runner.ts
Normal 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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -62,7 +62,7 @@ const buttonRowStyles = css`
|
||||
|
||||
> button {
|
||||
max-width: min-content;
|
||||
flex: 1 1 50%;
|
||||
flex: 1 1 25%;
|
||||
}
|
||||
|
||||
> div {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -15,6 +15,7 @@ export type NotificationsExtensionMessage = {
|
||||
typeData?: NotificationTypeData;
|
||||
height?: number;
|
||||
error?: string;
|
||||
closedByUser?: boolean;
|
||||
fadeOutNotification?: boolean;
|
||||
params: object;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
libs/angular/src/platform/view-cache/index.ts
Normal file
1
libs/angular/src/platform/view-cache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";
|
||||
1
libs/angular/src/platform/view-cache/internal.ts
Normal file
1
libs/angular/src/platform/view-cache/internal.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NoopViewCacheService } from "./noop-view-cache.service";
|
||||
@@ -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,
|
||||
130
libs/angular/src/platform/view-cache/view-cache.md
Normal file
130
libs/angular/src/platform/view-cache/view-cache.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
2227
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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/**/*"],
|
||||
|
||||
Reference in New Issue
Block a user