mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
PM-19741 Adds a notification at login for at-risk passwords. (#14555)
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
@@ -2515,6 +2515,10 @@
|
||||
"change": {
|
||||
"message": "Change"
|
||||
},
|
||||
"changePassword": {
|
||||
"message": "Change password",
|
||||
"description": "Change password button for browser at risk notification on login."
|
||||
},
|
||||
"changeButtonTitle": {
|
||||
"message": "Change password - $ITEMNAME$",
|
||||
"placeholders": {
|
||||
@@ -2524,6 +2528,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"atRiskPassword": {
|
||||
"message": "At-risk password"
|
||||
},
|
||||
"atRiskPasswords": {
|
||||
"message": "At-risk passwords"
|
||||
},
|
||||
@@ -2558,6 +2565,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"atRiskChangePrompt": {
|
||||
"message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.",
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "Acme Corp"
|
||||
}
|
||||
},
|
||||
"description": "Notification body when a login triggers an at-risk password change request and the change password domain is known."
|
||||
},
|
||||
"atRiskNavigatePrompt": {
|
||||
"message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.",
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "Acme Corp"
|
||||
}
|
||||
},
|
||||
"description": "Notification body when a login triggers an at-risk password change request and no change password domain is provided."
|
||||
},
|
||||
"reviewAndChangeAtRiskPassword": {
|
||||
"message": "Review and change one at-risk password"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { SecurityTask } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { CollectionView } from "../../content/components/common-types";
|
||||
import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum";
|
||||
@@ -32,10 +35,17 @@ interface AddUnlockVaultQueueMessage extends NotificationQueueMessage {
|
||||
type: "unlock";
|
||||
}
|
||||
|
||||
interface AtRiskPasswordQueueMessage extends NotificationQueueMessage {
|
||||
type: "at-risk-password";
|
||||
organizationName: string;
|
||||
passwordChangeUri?: string;
|
||||
}
|
||||
|
||||
type NotificationQueueMessageItem =
|
||||
| AddLoginQueueMessage
|
||||
| AddChangePasswordQueueMessage
|
||||
| AddUnlockVaultQueueMessage;
|
||||
| AddUnlockVaultQueueMessage
|
||||
| AtRiskPasswordQueueMessage;
|
||||
|
||||
type LockedVaultPendingNotificationsData = {
|
||||
commandToRetry: {
|
||||
@@ -50,6 +60,13 @@ type LockedVaultPendingNotificationsData = {
|
||||
target: string;
|
||||
};
|
||||
|
||||
type AtRiskPasswordNotificationsData = {
|
||||
activeUserId: UserId;
|
||||
cipher: CipherView;
|
||||
securityTask: SecurityTask;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
type AdjustNotificationBarMessageData = {
|
||||
height: number;
|
||||
};
|
||||
@@ -76,7 +93,8 @@ type NotificationBackgroundExtensionMessage = {
|
||||
data?: Partial<LockedVaultPendingNotificationsData> &
|
||||
Partial<AdjustNotificationBarMessageData> &
|
||||
Partial<ChangePasswordMessageData> &
|
||||
Partial<UnlockVaultMessageData>;
|
||||
Partial<UnlockVaultMessageData> &
|
||||
Partial<AtRiskPasswordNotificationsData>;
|
||||
login?: AddLoginMessageData;
|
||||
folder?: string;
|
||||
edit?: boolean;
|
||||
@@ -101,10 +119,20 @@ type NotificationBackgroundExtensionMessageHandlers = {
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<CollectionView[]>;
|
||||
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgOpenAtRiskPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgTriggerAddLoginNotification: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
|
||||
bgTriggerChangedPasswordNotification: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
|
||||
bgTriggerAtRiskPasswordNotification: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
|
||||
bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void;
|
||||
bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
bgOpenAddEditVaultItemPopout: ({
|
||||
|
||||
@@ -275,7 +275,7 @@ describe("NotificationBackground", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bgAddLogin message handler", () => {
|
||||
describe("bgTriggerAddLoginNotification message handler", () => {
|
||||
let tab: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
|
||||
@@ -305,7 +305,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to add the login if the user is logged out", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
|
||||
@@ -319,7 +319,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to add the login if the login data does not contain a valid url", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
@@ -333,7 +333,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to add the login if the user with a locked vault has disabled the login notification", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
@@ -350,7 +350,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to add the login if the user with an unlocked vault has disabled the login notification", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
@@ -368,7 +368,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
@@ -390,7 +390,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to change the password for an existing login if the password has not changed", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
@@ -410,7 +410,10 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("adds the login to the queue if the user has a locked account", async () => {
|
||||
const login = { username: "test", password: "password", url: "https://example.com" };
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login,
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
|
||||
@@ -426,7 +429,10 @@ describe("NotificationBackground", () => {
|
||||
password: "password",
|
||||
url: "https://example.com",
|
||||
} as any;
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login,
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
@@ -441,7 +447,10 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => {
|
||||
const login = { username: "tEsT", password: "password", url: "https://example.com" };
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login,
|
||||
};
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true);
|
||||
getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true);
|
||||
@@ -464,7 +473,7 @@ describe("NotificationBackground", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bgChangedPassword message handler", () => {
|
||||
describe("bgTriggerChangedPasswordNotification message handler", () => {
|
||||
let tab: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
let pushChangePasswordToQueueSpy: jest.SpyInstance;
|
||||
@@ -482,7 +491,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: { newPassword: "newPassword", currentPassword: "currentPassword", url: "" },
|
||||
};
|
||||
|
||||
@@ -494,7 +503,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("adds a change password message to the queue if the user does not have an unlocked account", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
currentPassword: "currentPassword",
|
||||
@@ -517,7 +526,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
currentPassword: "currentPassword",
|
||||
@@ -538,7 +547,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
currentPassword: "currentPassword",
|
||||
@@ -560,7 +569,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("adds a change password message to the queue if a single cipher matches the passed current password", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
currentPassword: "currentPassword",
|
||||
@@ -588,7 +597,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
url: "https://example.com",
|
||||
@@ -609,7 +618,7 @@ describe("NotificationBackground", () => {
|
||||
|
||||
it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgChangedPassword",
|
||||
command: "bgTriggerChangedPasswordNotification",
|
||||
data: {
|
||||
newPassword: "newPassword",
|
||||
url: "https://example.com",
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { firstValueFrom, switchMap, map, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
getOrganizationById,
|
||||
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";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
import { CollectionView } from "../content/components/common-types";
|
||||
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
|
||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||
import { TemporaryNotificationChangeLoginService } from "../services/notification-change-login-password.service";
|
||||
|
||||
import {
|
||||
AddChangePasswordQueueMessage,
|
||||
@@ -81,14 +85,18 @@ export default class NotificationBackground {
|
||||
ExtensionCommand.AutofillIdentity,
|
||||
]);
|
||||
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
|
||||
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
|
||||
bgAdjustNotificationBar: ({ message, sender }) =>
|
||||
this.handleAdjustNotificationBarMessage(message, sender),
|
||||
bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender),
|
||||
bgTriggerAddLoginNotification: ({ message, sender }) =>
|
||||
this.triggerAddLoginNotification(message, sender),
|
||||
bgTriggerChangedPasswordNotification: ({ message, sender }) =>
|
||||
this.triggerChangedPasswordNotification(message, sender),
|
||||
bgTriggerAtRiskPasswordNotification: ({ message, sender }) =>
|
||||
this.triggerAtRiskPasswordNotification(message, sender),
|
||||
bgCloseNotificationBar: ({ message, sender }) =>
|
||||
this.handleCloseNotificationBarMessage(message, sender),
|
||||
bgOpenAtRisksPasswords: ({ message, sender }) =>
|
||||
this.handleOpenAtRisksPasswordsMessage(message, sender),
|
||||
bgOpenAtRiskPasswords: ({ message, sender }) =>
|
||||
this.handleOpenAtRiskPasswordsMessage(message, sender),
|
||||
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
|
||||
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
|
||||
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
|
||||
@@ -341,12 +349,17 @@ export default class NotificationBackground {
|
||||
tab: chrome.tabs.Tab,
|
||||
notificationQueueMessage: NotificationQueueMessageItem,
|
||||
) {
|
||||
const notificationType = notificationQueueMessage.type;
|
||||
const {
|
||||
type: notificationType,
|
||||
wasVaultLocked: isVaultLocked,
|
||||
launchTimestamp,
|
||||
...params
|
||||
} = notificationQueueMessage;
|
||||
|
||||
const typeData: NotificationTypeData = {
|
||||
isVaultLocked: notificationQueueMessage.wasVaultLocked,
|
||||
isVaultLocked,
|
||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||
launchTimestamp: notificationQueueMessage.launchTimestamp,
|
||||
launchTimestamp,
|
||||
};
|
||||
|
||||
switch (notificationType) {
|
||||
@@ -358,6 +371,7 @@ export default class NotificationBackground {
|
||||
await BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
|
||||
type: notificationType,
|
||||
typeData,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,6 +389,48 @@ export default class NotificationBackground {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to trigger the at risk password notification
|
||||
*
|
||||
* @param message - The extension message
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
async triggerAtRiskPasswordNotification(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<boolean> {
|
||||
const { activeUserId, securityTask, cipher } = message.data;
|
||||
const domain = Utils.getDomain(sender.tab.url);
|
||||
const passwordChangeUri =
|
||||
await new TemporaryNotificationChangeLoginService().getChangePasswordUrl(cipher);
|
||||
|
||||
const authStatus = await this.getAuthStatus();
|
||||
|
||||
const wasVaultLocked = authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
const organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(activeUserId)
|
||||
.pipe(getOrganizationById(securityTask.organizationId)),
|
||||
);
|
||||
|
||||
this.removeTabFromNotificationQueue(sender.tab);
|
||||
const launchTimestamp = new Date().getTime();
|
||||
const queueMessage: NotificationQueueMessageItem = {
|
||||
domain,
|
||||
wasVaultLocked,
|
||||
type: NotificationQueueMessageType.AtRiskPassword,
|
||||
passwordChangeUri,
|
||||
organizationName: organization.name,
|
||||
tab: sender.tab,
|
||||
launchTimestamp,
|
||||
expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS),
|
||||
};
|
||||
this.notificationQueue.push(queueMessage);
|
||||
await this.checkNotificationQueue(sender.tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a login message to the notification queue, prompting the user to save
|
||||
* the login if it does not already exist in the vault. If the cipher exists
|
||||
@@ -383,20 +439,20 @@ export default class NotificationBackground {
|
||||
* @param message - The message to add to the queue
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
async addLogin(
|
||||
async triggerAddLoginNotification(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
): Promise<boolean> {
|
||||
const authStatus = await this.getAuthStatus();
|
||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const loginInfo = message.login;
|
||||
const normalizedUsername = loginInfo.username ? loginInfo.username.toLowerCase() : "";
|
||||
const loginDomain = Utils.getDomain(loginInfo.url);
|
||||
if (loginDomain == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const addLoginIsEnabled = await this.getEnableAddedLoginPrompt();
|
||||
@@ -406,14 +462,14 @@ export default class NotificationBackground {
|
||||
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true);
|
||||
}
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (activeUserId == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const ciphers = await this.cipherService.getAllDecryptedForUrl(loginInfo.url, activeUserId);
|
||||
@@ -422,7 +478,7 @@ export default class NotificationBackground {
|
||||
);
|
||||
if (addLoginIsEnabled && usernameMatches.length === 0) {
|
||||
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt();
|
||||
@@ -438,7 +494,9 @@ export default class NotificationBackground {
|
||||
loginInfo.password,
|
||||
sender.tab,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async pushAddLoginToQueue(
|
||||
@@ -472,14 +530,14 @@ export default class NotificationBackground {
|
||||
* @param message - The message to add to the queue
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
async changedPassword(
|
||||
async triggerChangedPasswordNotification(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
const changeData = message.data as ChangePasswordMessageData;
|
||||
const loginDomain = Utils.getDomain(changeData.url);
|
||||
if (loginDomain == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||
@@ -490,7 +548,7 @@ export default class NotificationBackground {
|
||||
sender.tab,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
let id: string = null;
|
||||
@@ -498,7 +556,7 @@ export default class NotificationBackground {
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (activeUserId == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const ciphers = await this.cipherService.getAllDecryptedForUrl(changeData.url, activeUserId);
|
||||
@@ -514,7 +572,9 @@ export default class NotificationBackground {
|
||||
}
|
||||
if (id != null) {
|
||||
await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, sender.tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -900,7 +960,7 @@ export default class NotificationBackground {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getSecurityTasks(userId: UserId) {
|
||||
async getSecurityTasks(userId: UserId) {
|
||||
let tasks: SecurityTask[] = [];
|
||||
|
||||
if (userId) {
|
||||
@@ -1074,7 +1134,7 @@ export default class NotificationBackground {
|
||||
* @param message - The extension message
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
private async handleOpenAtRisksPasswordsMessage(
|
||||
private async handleOpenAtRiskPasswordsMessage(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
@@ -24,6 +27,9 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou
|
||||
describe("OverlayNotificationsBackground", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let notificationBackground: NotificationBackground;
|
||||
let taskService: TaskService;
|
||||
let accountService: AccountService;
|
||||
let cipherService: CipherService;
|
||||
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
|
||||
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
|
||||
let overlayNotificationsBackground: OverlayNotificationsBackground;
|
||||
@@ -32,6 +38,9 @@ describe("OverlayNotificationsBackground", () => {
|
||||
jest.useFakeTimers();
|
||||
logService = mock<LogService>();
|
||||
notificationBackground = mock<NotificationBackground>();
|
||||
taskService = mock<TaskService>();
|
||||
accountService = mock<AccountService>();
|
||||
cipherService = mock<CipherService>();
|
||||
getEnableChangedPasswordPromptSpy = jest
|
||||
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
|
||||
.mockResolvedValue(true);
|
||||
@@ -41,6 +50,9 @@ describe("OverlayNotificationsBackground", () => {
|
||||
overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
logService,
|
||||
notificationBackground,
|
||||
taskService,
|
||||
accountService,
|
||||
cipherService,
|
||||
);
|
||||
await overlayNotificationsBackground.init();
|
||||
});
|
||||
@@ -329,8 +341,11 @@ describe("OverlayNotificationsBackground", () => {
|
||||
tab: { id: 1 },
|
||||
url: "https://example.com",
|
||||
});
|
||||
notificationChangedPasswordSpy = jest.spyOn(notificationBackground, "changedPassword");
|
||||
notificationAddLoginSpy = jest.spyOn(notificationBackground, "addLogin");
|
||||
notificationChangedPasswordSpy = jest.spyOn(
|
||||
notificationBackground,
|
||||
"triggerChangedPasswordNotification",
|
||||
);
|
||||
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
|
||||
|
||||
sendMockExtensionMessage(
|
||||
{ command: "collectPageDetailsResponse", details: pageDetails },
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Subject, switchMap, timer } from "rxjs";
|
||||
import { firstValueFrom, Subject, switchMap, timer } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
|
||||
@@ -19,6 +25,12 @@ import {
|
||||
} from "./abstractions/overlay-notifications.background";
|
||||
import NotificationBackground from "./notification.background";
|
||||
|
||||
type LoginSecurityTaskInfo = {
|
||||
securityTask: SecurityTask;
|
||||
cipher: CipherView;
|
||||
uri: ModifyLoginCipherFormData["uri"];
|
||||
};
|
||||
|
||||
export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface {
|
||||
private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map();
|
||||
private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set();
|
||||
@@ -35,6 +47,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private notificationBackground: NotificationBackground,
|
||||
private taskService: TaskService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -259,8 +274,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
|
||||
return (
|
||||
!modifyLoginData ||
|
||||
!this.shouldTriggerAddLoginNotification(modifyLoginData) ||
|
||||
!this.shouldTriggerChangePasswordNotification(modifyLoginData)
|
||||
!this.shouldAttemptAddLoginNotification(modifyLoginData) ||
|
||||
!this.shouldAttemptChangedPasswordNotification(modifyLoginData)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -404,10 +419,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
tab: chrome.tabs.Tab,
|
||||
) => {
|
||||
if (this.shouldTriggerChangePasswordNotification(modifyLoginData)) {
|
||||
let result: string;
|
||||
if (this.shouldAttemptChangedPasswordNotification(modifyLoginData)) {
|
||||
// These notifications are temporarily setup as "messages" to the notification background.
|
||||
// This will be structured differently in a future refactor.
|
||||
await this.notificationBackground.changedPassword(
|
||||
const success = await this.notificationBackground.triggerChangedPasswordNotification(
|
||||
{
|
||||
command: "bgChangedPassword",
|
||||
data: {
|
||||
@@ -418,14 +434,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
},
|
||||
{ tab },
|
||||
);
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
return;
|
||||
if (!success) {
|
||||
result = "Unqualified changedPassword notification attempt.";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldTriggerAddLoginNotification(modifyLoginData)) {
|
||||
await this.notificationBackground.addLogin(
|
||||
if (this.shouldAttemptAddLoginNotification(modifyLoginData)) {
|
||||
const success = await this.notificationBackground.triggerAddLoginNotification(
|
||||
{
|
||||
command: "bgAddLogin",
|
||||
command: "bgTriggerAddLoginNotification",
|
||||
login: {
|
||||
url: modifyLoginData.uri,
|
||||
username: modifyLoginData.username,
|
||||
@@ -434,8 +451,44 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
},
|
||||
{ tab },
|
||||
);
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
if (!success) {
|
||||
result = "Unqualified addLogin notification attempt.";
|
||||
}
|
||||
}
|
||||
|
||||
const shouldGetTasks =
|
||||
(await this.notificationBackground.getNotificationFlag()) && !modifyLoginData.newPassword;
|
||||
|
||||
if (shouldGetTasks) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
|
||||
if (activeUserId) {
|
||||
const loginSecurityTaskInfo = await this.getSecurityTaskAndCipherForLoginData(
|
||||
modifyLoginData,
|
||||
activeUserId,
|
||||
);
|
||||
|
||||
if (loginSecurityTaskInfo) {
|
||||
await this.notificationBackground.triggerAtRiskPasswordNotification(
|
||||
{
|
||||
command: "bgTriggerAtRiskPasswordNotification",
|
||||
data: {
|
||||
activeUserId,
|
||||
cipher: loginSecurityTaskInfo.cipher,
|
||||
securityTask: loginSecurityTaskInfo.securityTask,
|
||||
},
|
||||
},
|
||||
{ tab },
|
||||
);
|
||||
} else {
|
||||
result = "Unqualified atRiskPassword notification attempt.";
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -443,7 +496,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
*
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private shouldTriggerChangePasswordNotification = (
|
||||
private shouldAttemptChangedPasswordNotification = (
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
) => {
|
||||
return modifyLoginData?.newPassword && !modifyLoginData.username;
|
||||
@@ -454,10 +507,66 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
*
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
|
||||
private shouldAttemptAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
|
||||
return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword);
|
||||
};
|
||||
|
||||
/**
|
||||
* If there is a security task for this cipher at login, return the task, cipher view, and uri.
|
||||
*
|
||||
* @param modifyLoginData - The modified login form data
|
||||
* @param activeUserId - The currently logged in user ID
|
||||
*/
|
||||
private async getSecurityTaskAndCipherForLoginData(
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
activeUserId: UserId,
|
||||
): Promise<LoginSecurityTaskInfo | null> {
|
||||
const tasks: SecurityTask[] = await this.notificationBackground.getSecurityTasks(activeUserId);
|
||||
if (!tasks?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl(
|
||||
modifyLoginData.uri,
|
||||
activeUserId,
|
||||
);
|
||||
if (!urlCiphers?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const securityTaskForLogin = urlCiphers.reduce(
|
||||
(taskInfo: LoginSecurityTaskInfo | null, cipher: CipherView) => {
|
||||
if (
|
||||
// exit early if info was found already
|
||||
taskInfo ||
|
||||
// exit early if the cipher was deleted
|
||||
cipher.deletedDate ||
|
||||
// exit early if the entered login info doesn't match an existing cipher
|
||||
modifyLoginData.username !== cipher.login.username ||
|
||||
modifyLoginData.password !== cipher.login.password
|
||||
) {
|
||||
return taskInfo;
|
||||
}
|
||||
|
||||
// Find the first security task for the cipherId belonging to the entered login
|
||||
const cipherSecurityTask = tasks.find(
|
||||
({ cipherId, status }) =>
|
||||
cipher.id === cipherId && // match security task cipher id to url cipher id
|
||||
status === SecurityTaskStatus.Pending, // security task has not been completed
|
||||
);
|
||||
|
||||
if (cipherSecurityTask) {
|
||||
return { securityTask: cipherSecurityTask, cipher, uri: modifyLoginData.uri };
|
||||
}
|
||||
|
||||
return taskInfo;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
return securityTaskForLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the completed web request and removes the modified login form data for the tab.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ActionButtonProps = {
|
||||
disabled?: boolean;
|
||||
theme: Theme;
|
||||
handleClick: (e: Event) => void;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export function ActionButton({
|
||||
@@ -17,6 +18,7 @@ export function ActionButton({
|
||||
disabled = false,
|
||||
theme,
|
||||
handleClick,
|
||||
fullWidth = true,
|
||||
}: ActionButtonProps) {
|
||||
const handleButtonClick = (event: Event) => {
|
||||
if (!disabled) {
|
||||
@@ -26,7 +28,7 @@ export function ActionButton({
|
||||
|
||||
return html`
|
||||
<button
|
||||
class=${actionButtonStyles({ disabled, theme })}
|
||||
class=${actionButtonStyles({ disabled, theme, fullWidth })}
|
||||
title=${buttonText}
|
||||
type="button"
|
||||
@click=${handleButtonClick}
|
||||
@@ -36,14 +38,22 @@ export function ActionButton({
|
||||
`;
|
||||
}
|
||||
|
||||
const actionButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css`
|
||||
const actionButtonStyles = ({
|
||||
disabled,
|
||||
theme,
|
||||
fullWidth,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
theme: Theme;
|
||||
fullWidth: boolean;
|
||||
}) => css`
|
||||
${typography.body2}
|
||||
|
||||
user-select: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${border.radius.full};
|
||||
padding: ${spacing["1"]} ${spacing["3"]};
|
||||
width: 100%;
|
||||
width: ${fullWidth ? "100%" : "auto"};
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { css } from "@emotion/css";
|
||||
import { html } from "lit";
|
||||
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { spacing, themes } from "../../constants/styles";
|
||||
import { ExternalLink } from "../../icons";
|
||||
|
||||
export function AdditionalTasksButtonContent({
|
||||
buttonText,
|
||||
theme,
|
||||
}: {
|
||||
buttonText: string;
|
||||
theme: Theme;
|
||||
}) {
|
||||
return html`
|
||||
<div class=${additionalTasksButtonContentStyles({ theme })}>
|
||||
<span>${buttonText}</span>
|
||||
${ExternalLink({ theme, color: themes[theme].text.contrast })}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const additionalTasksButtonContentStyles = ({ theme }: { theme: Theme }) => css`
|
||||
gap: ${spacing[2]};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
@@ -103,6 +103,12 @@ export const mockTasks = [
|
||||
|
||||
export const mockI18n = {
|
||||
appName: "Bitwarden",
|
||||
atRiskPassword: "At-risk password",
|
||||
atRiskNavigatePrompt:
|
||||
"$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.",
|
||||
atRiskChangePrompt:
|
||||
"Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.",
|
||||
changePassword: "Change password",
|
||||
close: "Close",
|
||||
collection: "Collection",
|
||||
folder: "Folder",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Meta, StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import {
|
||||
AtRiskNotification,
|
||||
AtRiskNotificationProps,
|
||||
} from "../../../notification/at-risk-password/container";
|
||||
import { mockI18n, mockBrowserI18nGetMessage } from "../../mock-data";
|
||||
|
||||
export default {
|
||||
title: "Components/Notifications/At-Risk Notification",
|
||||
argTypes: {
|
||||
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
|
||||
},
|
||||
args: {
|
||||
theme: ThemeTypes.Light,
|
||||
handleCloseNotification: () => alert("Close notification action triggered"),
|
||||
params: {
|
||||
passwordChangeUri: "https://webtests.dev/.well-known/change-password", // Remove to see "navigate" version of notification
|
||||
organizationName: "Acme Co.",
|
||||
},
|
||||
i18n: mockI18n,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=485-20160&m=dev",
|
||||
},
|
||||
},
|
||||
} as Meta<AtRiskNotificationProps>;
|
||||
|
||||
const Template = (args: AtRiskNotificationProps) => AtRiskNotification({ ...args });
|
||||
|
||||
export const Default: StoryObj<AtRiskNotificationProps> = {
|
||||
render: Template,
|
||||
};
|
||||
|
||||
window.chrome = {
|
||||
...window.chrome,
|
||||
i18n: {
|
||||
getMessage: mockBrowserI18nGetMessage,
|
||||
},
|
||||
} as typeof chrome;
|
||||
@@ -0,0 +1,49 @@
|
||||
import createEmotion from "@emotion/css/create-instance";
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { spacing, themes } from "../../constants/styles";
|
||||
import { Warning } from "../../illustrations";
|
||||
|
||||
import { AtRiskNotificationMessage } from "./message";
|
||||
|
||||
export const componentClassPrefix = "at-risk-notification-body";
|
||||
|
||||
const { css } = createEmotion({
|
||||
key: componentClassPrefix,
|
||||
});
|
||||
|
||||
export type AtRiskNotificationBodyProps = {
|
||||
riskMessage: string;
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
export function AtRiskNotificationBody({ riskMessage, theme }: AtRiskNotificationBodyProps) {
|
||||
return html`
|
||||
<div class=${atRiskNotificationBodyStyles({ theme })}>
|
||||
<div class=${iconContainerStyles}>${Warning()}</div>
|
||||
${riskMessage
|
||||
? AtRiskNotificationMessage({
|
||||
message: riskMessage,
|
||||
theme,
|
||||
})
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const iconContainerStyles = css`
|
||||
> svg {
|
||||
width: 50px;
|
||||
height: auto;
|
||||
}
|
||||
`;
|
||||
const atRiskNotificationBodyStyles = ({ theme }: { theme: Theme }) => css`
|
||||
gap: ${spacing[4]};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background-color: ${themes[theme].background.alt};
|
||||
padding: 12px;
|
||||
`;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { css } from "@emotion/css";
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { NotificationBarIframeInitData } from "../../../../notification/abstractions/notification-bar";
|
||||
import { I18n } from "../../common-types";
|
||||
import { themes, spacing } from "../../constants/styles";
|
||||
import {
|
||||
NotificationHeader,
|
||||
componentClassPrefix as notificationHeaderClassPrefix,
|
||||
} from "../header";
|
||||
|
||||
import { AtRiskNotificationBody } from "./body";
|
||||
import { AtRiskNotificationFooter } from "./footer";
|
||||
|
||||
export type AtRiskNotificationProps = NotificationBarIframeInitData & {
|
||||
handleCloseNotification: (e: Event) => void;
|
||||
} & {
|
||||
i18n: I18n;
|
||||
};
|
||||
|
||||
export function AtRiskNotification({
|
||||
handleCloseNotification,
|
||||
i18n,
|
||||
theme = ThemeTypes.Light,
|
||||
params,
|
||||
}: AtRiskNotificationProps) {
|
||||
const { passwordChangeUri, organizationName } = params;
|
||||
const riskMessage = chrome.i18n.getMessage(
|
||||
passwordChangeUri ? "atRiskChangePrompt" : "atRiskNavigatePrompt",
|
||||
organizationName,
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class=${atRiskNotificationContainerStyles(theme)}>
|
||||
${NotificationHeader({
|
||||
handleCloseNotification,
|
||||
i18n,
|
||||
message: i18n.atRiskPassword,
|
||||
theme,
|
||||
})}
|
||||
${AtRiskNotificationBody({
|
||||
theme,
|
||||
riskMessage,
|
||||
})}
|
||||
${passwordChangeUri
|
||||
? AtRiskNotificationFooter({
|
||||
i18n,
|
||||
theme,
|
||||
passwordChangeUri: params?.passwordChangeUri,
|
||||
})
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const atRiskNotificationContainerStyles = (theme: Theme) => css`
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
border: 1px solid ${themes[theme].secondary["300"]};
|
||||
border-radius: ${spacing["4"]};
|
||||
box-shadow: -2px 4px 6px 0px #0000001a;
|
||||
background-color: ${themes[theme].background.alt};
|
||||
width: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
[class*="${notificationHeaderClassPrefix}-"] {
|
||||
border-radius: ${spacing["4"]} ${spacing["4"]} 0 0;
|
||||
border-bottom: 0.5px solid ${themes[theme].secondary["300"]};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { css } from "@emotion/css";
|
||||
import { html } from "lit";
|
||||
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { ActionButton } from "../../buttons/action-button";
|
||||
import { AdditionalTasksButtonContent } from "../../buttons/additional-tasks/button-content";
|
||||
import { I18n } from "../../common-types";
|
||||
import { spacing } from "../../constants/styles";
|
||||
|
||||
export type AtRiskNotificationFooterProps = {
|
||||
i18n: I18n;
|
||||
theme: Theme;
|
||||
passwordChangeUri: string;
|
||||
};
|
||||
|
||||
export function AtRiskNotificationFooter({
|
||||
i18n,
|
||||
theme,
|
||||
passwordChangeUri,
|
||||
}: AtRiskNotificationFooterProps) {
|
||||
return html`<div class=${atRiskNotificationFooterStyles}>
|
||||
${passwordChangeUri &&
|
||||
ActionButton({
|
||||
handleClick: () => {
|
||||
open(passwordChangeUri, "_blank");
|
||||
},
|
||||
buttonText: AdditionalTasksButtonContent({ buttonText: i18n.changePassword, theme }),
|
||||
theme,
|
||||
fullWidth: false,
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const atRiskNotificationFooterStyles = css`
|
||||
display: flex;
|
||||
padding: ${spacing[2]} ${spacing[4]} ${spacing[4]} ${spacing[4]};
|
||||
|
||||
:last-child {
|
||||
border-radius: 0 0 ${spacing["4"]} ${spacing["4"]};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { css } from "@emotion/css";
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { themes } from "../../constants/styles";
|
||||
|
||||
export type AtRiskNotificationMessageProps = {
|
||||
message?: string;
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
export function AtRiskNotificationMessage({ message, theme }: AtRiskNotificationMessageProps) {
|
||||
return html`
|
||||
<div>
|
||||
${message
|
||||
? html`
|
||||
<span title=${message} class=${atRiskNotificationMessageStyles(theme)}>
|
||||
${message}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const baseTextStyles = css`
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 24px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const atRiskNotificationMessageStyles = (theme: Theme) => css`
|
||||
${baseTextStyles}
|
||||
|
||||
color: ${themes[theme].text.main};
|
||||
font-weight: 400;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
display: inline;
|
||||
`;
|
||||
@@ -2,6 +2,7 @@ const NotificationQueueMessageType = {
|
||||
AddLogin: "add",
|
||||
ChangePassword: "change",
|
||||
UnlockVault: "unlock",
|
||||
AtRiskPassword: "at-risk-password",
|
||||
} as const;
|
||||
|
||||
type NotificationQueueMessageTypes =
|
||||
|
||||
@@ -11,6 +11,7 @@ const NotificationTypes = {
|
||||
Add: "add",
|
||||
Change: "change",
|
||||
Unlock: "unlock",
|
||||
AtRiskPassword: "at-risk-password",
|
||||
} as const;
|
||||
|
||||
type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes];
|
||||
@@ -30,7 +31,8 @@ type NotificationBarIframeInitData = {
|
||||
organizations?: OrgView[];
|
||||
removeIndividualVault?: boolean;
|
||||
theme?: Theme;
|
||||
type?: string; // @TODO use `NotificationType`
|
||||
type?: NotificationType;
|
||||
params?: AtRiskPasswordNotificationParams | any;
|
||||
};
|
||||
|
||||
type NotificationBarWindowMessage = {
|
||||
@@ -50,7 +52,13 @@ type NotificationBarWindowMessageHandlers = {
|
||||
saveCipherAttemptCompleted: ({ message }: { message: NotificationBarWindowMessage }) => void;
|
||||
};
|
||||
|
||||
type AtRiskPasswordNotificationParams = {
|
||||
passwordChangeUri?: string;
|
||||
organizationName: string;
|
||||
};
|
||||
|
||||
export {
|
||||
AtRiskPasswordNotificationParams,
|
||||
NotificationTaskInfo,
|
||||
NotificationTypes,
|
||||
NotificationType,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view
|
||||
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
|
||||
import { NotificationCipherData } from "../content/components/cipher/types";
|
||||
import { CollectionView, I18n, OrgView } from "../content/components/common-types";
|
||||
import { AtRiskNotification } from "../content/components/notification/at-risk-password/container";
|
||||
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container";
|
||||
import { NotificationContainer } from "../content/components/notification/container";
|
||||
import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
|
||||
@@ -56,21 +57,24 @@ function applyNotificationBarStyle() {
|
||||
function getI18n() {
|
||||
return {
|
||||
appName: chrome.i18n.getMessage("appName"),
|
||||
atRiskPassword: chrome.i18n.getMessage("atRiskPassword"),
|
||||
changePassword: chrome.i18n.getMessage("changePassword"),
|
||||
close: chrome.i18n.getMessage("close"),
|
||||
collection: chrome.i18n.getMessage("collection"),
|
||||
folder: chrome.i18n.getMessage("folder"),
|
||||
loginSaveConfirmation: chrome.i18n.getMessage("loginSaveConfirmation"),
|
||||
loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"),
|
||||
loginUpdatedConfirmation: chrome.i18n.getMessage("loginUpdatedConfirmation"),
|
||||
loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"),
|
||||
loginUpdateTaskSuccess: chrome.i18n.getMessage("loginUpdateTaskSuccess"),
|
||||
loginUpdateTaskSuccessAdditional: chrome.i18n.getMessage("loginUpdateTaskSuccessAdditional"),
|
||||
nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
|
||||
newItem: chrome.i18n.getMessage("newItem"),
|
||||
never: chrome.i18n.getMessage("never"),
|
||||
myVault: chrome.i18n.getMessage("myVault"),
|
||||
never: chrome.i18n.getMessage("never"),
|
||||
newItem: chrome.i18n.getMessage("newItem"),
|
||||
nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
|
||||
notificationAddDesc: chrome.i18n.getMessage("notificationAddDesc"),
|
||||
notificationAddSave: chrome.i18n.getMessage("notificationAddSave"),
|
||||
notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
|
||||
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
|
||||
notificationEdit: chrome.i18n.getMessage("edit"),
|
||||
notificationEditTooltip: chrome.i18n.getMessage("notificationEditTooltip"),
|
||||
notificationLoginSaveConfirmation: chrome.i18n.getMessage("notificationLoginSaveConfirmation"),
|
||||
@@ -79,6 +83,7 @@ function getI18n() {
|
||||
),
|
||||
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
|
||||
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
|
||||
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
|
||||
notificationViewAria: chrome.i18n.getMessage("notificationViewAria"),
|
||||
saveAction: chrome.i18n.getMessage("notificationAddSave"),
|
||||
saveAsNewLoginAction: chrome.i18n.getMessage("saveAsNewLoginAction"),
|
||||
@@ -87,8 +92,8 @@ function getI18n() {
|
||||
saveLogin: chrome.i18n.getMessage("saveLogin"),
|
||||
typeLogin: chrome.i18n.getMessage("typeLogin"),
|
||||
unlockToSave: chrome.i18n.getMessage("unlockToSave"),
|
||||
updateLoginAction: chrome.i18n.getMessage("updateLoginAction"),
|
||||
updateLogin: chrome.i18n.getMessage("updateLogin"),
|
||||
updateLoginAction: chrome.i18n.getMessage("updateLoginAction"),
|
||||
vault: chrome.i18n.getMessage("vault"),
|
||||
view: chrome.i18n.getMessage("view"),
|
||||
};
|
||||
@@ -124,6 +129,7 @@ export function getNotificationHeaderMessage(i18n: I18n, type?: NotificationType
|
||||
[NotificationTypes.Add]: i18n.saveLogin,
|
||||
[NotificationTypes.Change]: i18n.updateLogin,
|
||||
[NotificationTypes.Unlock]: i18n.unlockToSave,
|
||||
[NotificationTypes.AtRiskPassword]: i18n.atRiskPassword,
|
||||
}[type]
|
||||
: undefined;
|
||||
}
|
||||
@@ -143,6 +149,7 @@ export function getConfirmationHeaderMessage(i18n: I18n, type?: NotificationType
|
||||
[NotificationTypes.Add]: i18n.loginSaveSuccess,
|
||||
[NotificationTypes.Change]: i18n.loginUpdateSuccess,
|
||||
[NotificationTypes.Unlock]: "",
|
||||
[NotificationTypes.AtRiskPassword]: "",
|
||||
}[type]
|
||||
: undefined;
|
||||
}
|
||||
@@ -193,6 +200,7 @@ export function getNotificationTestId(
|
||||
[NotificationTypes.Unlock]: "unlock-notification-bar",
|
||||
[NotificationTypes.Add]: "save-notification-bar",
|
||||
[NotificationTypes.Change]: "update-notification-bar",
|
||||
[NotificationTypes.AtRiskPassword]: "at-risk-password-notification-bar",
|
||||
}[notificationType];
|
||||
}
|
||||
|
||||
@@ -262,7 +270,24 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle AtRiskPasswordNotification render
|
||||
if (notificationBarIframeInitData.type === NotificationTypes.AtRiskPassword) {
|
||||
return render(
|
||||
AtRiskNotification({
|
||||
...notificationBarIframeInitData,
|
||||
type: notificationBarIframeInitData.type as NotificationType,
|
||||
theme: resolvedTheme,
|
||||
i18n,
|
||||
params: initData.params,
|
||||
handleCloseNotification,
|
||||
}),
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// Default scenario: add or update password
|
||||
const orgId = selectedVaultSignal.get();
|
||||
|
||||
await Promise.all([
|
||||
new Promise<OrgView[]>((resolve) =>
|
||||
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
|
||||
@@ -533,7 +558,7 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
|
||||
...notificationBarIframeInitData,
|
||||
error,
|
||||
handleCloseNotification,
|
||||
handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRisksPasswords" }),
|
||||
handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRiskPasswords" }),
|
||||
handleOpenVault: (e: Event) =>
|
||||
cipherId ? openViewVaultItemPopout(cipherId) : openAddEditVaultItemPopout(e, {}),
|
||||
headerMessage,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type NotificationsExtensionMessage = {
|
||||
error?: string;
|
||||
closedByUser?: boolean;
|
||||
fadeOutNotification?: boolean;
|
||||
params: object;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// @ts-strict-ignore
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { NotificationBarIframeInitData } from "../../../notification/abstractions/notification-bar";
|
||||
import {
|
||||
NotificationBarIframeInitData,
|
||||
NotificationType,
|
||||
NotificationTypes,
|
||||
} from "../../../notification/abstractions/notification-bar";
|
||||
import { sendExtensionMessage, setElementStyles } from "../../../utils";
|
||||
import {
|
||||
NotificationsExtensionMessage,
|
||||
@@ -15,8 +19,7 @@ export class OverlayNotificationsContentService
|
||||
{
|
||||
private notificationBarElement: HTMLElement | null = null;
|
||||
private notificationBarIframeElement: HTMLIFrameElement | null = null;
|
||||
private currentNotificationBarType: string | null = null;
|
||||
private removeTabFromNotificationQueueTypes = new Set(["add", "change"]);
|
||||
private currentNotificationBarType: NotificationType | null = null;
|
||||
private notificationRefreshFlag: boolean = false;
|
||||
private notificationBarElementStyles: Partial<CSSStyleDeclaration> = {
|
||||
height: "82px",
|
||||
@@ -79,17 +82,19 @@ export class OverlayNotificationsContentService
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, typeData } = message.data;
|
||||
const { type, typeData, params } = message.data;
|
||||
|
||||
if (this.currentNotificationBarType && type !== this.currentNotificationBarType) {
|
||||
this.closeNotificationBar();
|
||||
}
|
||||
const initData = {
|
||||
type,
|
||||
type: type as NotificationType,
|
||||
isVaultLocked: typeData.isVaultLocked,
|
||||
theme: typeData.theme,
|
||||
removeIndividualVault: typeData.removeIndividualVault,
|
||||
importType: typeData.importType,
|
||||
launchTimestamp: typeData.launchTimestamp,
|
||||
params,
|
||||
};
|
||||
|
||||
if (globalThis.document.readyState === "loading") {
|
||||
@@ -291,10 +296,13 @@ export class OverlayNotificationsContentService
|
||||
this.notificationBarElement.remove();
|
||||
this.notificationBarElement = null;
|
||||
|
||||
if (
|
||||
closedByUserAction &&
|
||||
this.removeTabFromNotificationQueueTypes.has(this.currentNotificationBarType)
|
||||
) {
|
||||
const removableNotificationTypes = new Set([
|
||||
NotificationTypes.Add,
|
||||
NotificationTypes.Change,
|
||||
NotificationTypes.AtRiskPassword,
|
||||
] as NotificationType[]);
|
||||
|
||||
if (closedByUserAction && removableNotificationTypes.has(this.currentNotificationBarType)) {
|
||||
void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
// Duplicates Default Change Login Password Service, for now
|
||||
// Since the former is an Angular injectable service, and we
|
||||
// need to use the function inside of lit components.
|
||||
// If primary service can be abstracted, that would be ideal.
|
||||
|
||||
export class TemporaryNotificationChangeLoginService {
|
||||
async getChangePasswordUrl(cipher: CipherView, fallback = false): Promise<string | null> {
|
||||
// Ensure we have a cipher with at least one URI
|
||||
if (cipher.type !== CipherType.Login || cipher.login == null || !cipher.login.hasUris) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter for valid URLs that are HTTP(S)
|
||||
const urls = cipher.login.uris
|
||||
.map((m) => Utils.getUrl(m.uri))
|
||||
.filter((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
|
||||
|
||||
if (urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
const [reliable, wellKnownChangeUrl] = await Promise.all([
|
||||
this.hasReliableHttpStatusCode(url.origin),
|
||||
this.getWellKnownChangePasswordUrl(url.origin),
|
||||
]);
|
||||
|
||||
// Some servers return a 200 OK for a resource that should not exist
|
||||
// Which means we cannot trust the well-known URL is valid, so we skip it
|
||||
// to avoid potentially sending users to a 404 page
|
||||
if (reliable && wellKnownChangeUrl != null) {
|
||||
return wellKnownChangeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// No reliable well-known URL found, fallback to the first URL
|
||||
|
||||
// @TODO reimplement option in original service to indicate if no URL found.
|
||||
// return urls[0].href; (originally)
|
||||
return fallback ? urls[0].href : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server returns a non-200 status code for a resource that should not exist.
|
||||
* See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics
|
||||
* @param urlOrigin The origin of the URL to check
|
||||
*/
|
||||
private async hasReliableHttpStatusCode(urlOrigin: string): Promise<boolean> {
|
||||
try {
|
||||
const url = new URL(
|
||||
"./.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
|
||||
urlOrigin,
|
||||
);
|
||||
|
||||
const request = new Request(url, {
|
||||
method: "GET",
|
||||
mode: "same-origin",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const response = await fetch(request);
|
||||
return !response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response
|
||||
* is returned. Returns null if the request throws or the response is not 200 OK.
|
||||
* See https://w3c.github.io/webappsec-change-password-url/
|
||||
* @param urlOrigin The origin of the URL to check
|
||||
*/
|
||||
private async getWellKnownChangePasswordUrl(urlOrigin: string): Promise<string | null> {
|
||||
try {
|
||||
const url = new URL("./.well-known/change-password", urlOrigin);
|
||||
|
||||
const request = new Request(url, {
|
||||
method: "GET",
|
||||
mode: "same-origin",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const response = await fetch(request);
|
||||
|
||||
return response.ok ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// This test skips all the initilization of the background script and just
|
||||
// focuses on making sure we don't accidently delete the initilization of
|
||||
// This test skips all the initialization of the background script and just
|
||||
// focuses on making sure we don't accidentally delete the initialization of
|
||||
// background vault syncing. This has happened before!
|
||||
describe("MainBackground sync task scheduling", () => {
|
||||
it("includes code to schedule the sync interval task", () => {
|
||||
|
||||
@@ -1230,6 +1230,9 @@ export default class MainBackground {
|
||||
this.overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
this.logService,
|
||||
this.notificationBackground,
|
||||
this.taskService,
|
||||
this.accountService,
|
||||
this.cipherService,
|
||||
);
|
||||
|
||||
this.autoSubmitLoginBackground = new AutoSubmitLoginBackground(
|
||||
|
||||
Reference in New Issue
Block a user