1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-11 10:10:25 +01:00

[PM-10079] Add keyboard shortcut to autofill identity and credit cards (#10254)

* [BEEEP] Autofill Identity and Card Ciphers From Keyboard Shortcut

* [PM-10079] Add keyboard shortcut to autofill identity and credit card ciphers

* [PM-10079] Fixing jest tests

* [PM-10079] Added an enum for the autofill commands, and adjusted how we filter out cipher types before sorting them by last used when calling for ID and card ciphers

* [PM-10079] Updating copywriting for the autofill settings revolving around keyboard shortcuts

* [PM-10079] Setting a method within CipherService as private
This commit is contained in:
Cesar Gonzalez 2024-07-31 12:52:04 -05:00 committed by GitHub
parent 85c8ff04a1
commit 86acca3bec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 187 additions and 61 deletions

View File

@ -1343,8 +1343,14 @@
"commandOpenSidebar": {
"message": "Open vault in sidebar"
},
"commandAutofillDesc": {
"message": "Auto-fill the last used login for the current website"
"commandAutofillLoginDesc": {
"message": "Autofill the last used login for the current website"
},
"commandAutofillCardDesc": {
"message": "Autofill the last used card for the current website"
},
"commandAutofillIdentityDesc": {
"message": "Autofill the last used identity for the current website"
},
"commandGeneratePasswordDesc": {
"message": "Generate and copy a new random password to the clipboard"
@ -2774,14 +2780,17 @@
"autofillKeyboardShortcutUpdateLabel": {
"message": "Change shortcut"
},
"autofillKeyboardManagerShortcutsLabel": {
"message": "Manage shortcuts"
},
"autofillShortcut": {
"message": "Autofill keyboard shortcut"
},
"autofillShortcutNotSet": {
"message": "The autofill shortcut is not set. Change this in the browser's settings."
"autofillLoginShortcutNotSet": {
"message": "The autofill login shortcut is not set. Change this in the browser's settings."
},
"autofillShortcutText": {
"message": "The autofill shortcut is: $COMMAND$. Change this in the browser's settings.",
"autofillLoginShortcutText": {
"message": "The autofill login shortcut is $COMMAND$. Manage all shortcuts in the browser's settings.",
"placeholders": {
"command": {
"content": "$1",

View File

@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { ExtensionCommand } from "@bitwarden/common/autofill/constants";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -151,7 +152,7 @@ describe("NotificationBackground", () => {
const message: NotificationBackgroundExtensionMessage = {
command: "unlockCompleted",
data: {
commandToRetry: { message: { command: "autofill_login" } },
commandToRetry: { message: { command: ExtensionCommand.AutofillLogin } },
} as LockedVaultPendingNotificationsData,
};
jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation();

View File

@ -4,7 +4,11 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { NOTIFICATION_BAR_LIFESPAN_MS } from "@bitwarden/common/autofill/constants";
import {
ExtensionCommand,
ExtensionCommandType,
NOTIFICATION_BAR_LIFESPAN_MS,
} 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";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
@ -45,6 +49,11 @@ export default class NotificationBackground {
private openUnlockPopout = openUnlockPopout;
private openAddEditVaultItemPopout = openAddEditVaultItemPopout;
private notificationQueue: NotificationQueueMessageItem[] = [];
private allowedRetryCommands: Set<ExtensionCommandType> = new Set([
ExtensionCommand.AutofillLogin,
ExtensionCommand.AutofillCard,
ExtensionCommand.AutofillIdentity,
]);
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
bgGetFolderData: () => this.getFolderData(),
@ -689,8 +698,8 @@ export default class NotificationBackground {
sender: chrome.runtime.MessageSender,
): Promise<void> {
const messageData = message.data as LockedVaultPendingNotificationsData;
const retryCommand = messageData.commandToRetry.message.command;
if (retryCommand === "autofill_login") {
const retryCommand = messageData.commandToRetry.message.command as ExtensionCommandType;
if (this.allowedRetryCommands.has(retryCommand)) {
await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
}

View File

@ -16,6 +16,7 @@ import {
CREATE_CARD_ID,
CREATE_IDENTITY_ID,
CREATE_LOGIN_ID,
ExtensionCommand,
GENERATE_PASSWORD_ID,
NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants";
@ -79,7 +80,7 @@ export class ContextMenuClickedHandler {
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
const retryMessage: LockedVaultPendingNotificationsData = {
commandToRetry: {
message: { command: NOOP_COMMAND_SUFFIX, contextMenuOnClickData: info },
message: { command: ExtensionCommand.NoopCommand, contextMenuOnClickData: info },
sender: { tab: tab },
},
target: "contextmenus.background",

View File

@ -159,9 +159,9 @@ export class AutofillV1Component implements OnInit {
private async setAutofillKeyboardHelperText(command: string) {
if (command) {
this.autofillKeyboardHelperText = this.i18nService.t("autofillShortcutText", command);
this.autofillKeyboardHelperText = this.i18nService.t("autofillLoginShortcutText", command);
} else {
this.autofillKeyboardHelperText = this.i18nService.t("autofillShortcutNotSet");
this.autofillKeyboardHelperText = this.i18nService.t("autofillLoginShortcutNotSet");
}
}

View File

@ -107,7 +107,7 @@
</bit-section-header>
<bit-item>
<button bit-item-content type="button" (click)="openURI($event, browserShortcutsURI)">
<h3 bitTypography="h5">{{ "autofillKeyboardShortcutUpdateLabel" | i18n }}</h3>
<h3 bitTypography="h5">{{ "autofillKeyboardManagerShortcutsLabel" | i18n }}</h3>
<bit-hint slot="secondary" class="tw-text-sm tw-whitespace-normal">
{{ autofillKeyboardHelperText }}
</bit-hint>

View File

@ -215,9 +215,9 @@ export class AutofillComponent implements OnInit {
private async setAutofillKeyboardHelperText(command: string) {
if (command) {
this.autofillKeyboardHelperText = this.i18nService.t("autofillShortcutText", command);
this.autofillKeyboardHelperText = this.i18nService.t("autofillLoginShortcutText", command);
} else {
this.autofillKeyboardHelperText = this.i18nService.t("autofillShortcutNotSet");
this.autofillKeyboardHelperText = this.i18nService.t("autofillLoginShortcutNotSet");
}
}

View File

@ -1232,22 +1232,21 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab);
jest.spyOn(autofillService, "doAutoFill").mockImplementation();
jest
.spyOn(autofillService["cipherService"], "getAllDecryptedForUrl")
.mockResolvedValueOnce([cardCipher]);
.spyOn(autofillService["cipherService"], "getNextCardCipher")
.mockResolvedValueOnce(cardCipher);
await autofillService.doAutoFillActiveTab(cardFormPageDetails, false, CipherType.Card);
await autofillService.doAutoFillActiveTab(cardFormPageDetails, true, CipherType.Card);
expect(autofillService["cipherService"].getAllDecryptedForUrl).toHaveBeenCalled();
expect(autofillService.doAutoFill).toHaveBeenCalledWith({
tab: tab,
cipher: cardCipher,
pageDetails: cardFormPageDetails,
skipLastUsed: true,
skipUsernameOnlyFill: true,
onlyEmptyFields: true,
onlyVisibleFields: true,
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: false,
allowUntrustedIframe: false,
allowUntrustedIframe: true,
allowTotpAutofill: false,
});
});
@ -1280,26 +1279,21 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab);
jest.spyOn(autofillService, "doAutoFill").mockImplementation();
jest
.spyOn(autofillService["cipherService"], "getAllDecryptedForUrl")
.mockResolvedValueOnce([identityCipher]);
.spyOn(autofillService["cipherService"], "getNextIdentityCipher")
.mockResolvedValueOnce(identityCipher);
await autofillService.doAutoFillActiveTab(
identityFormPageDetails,
false,
CipherType.Identity,
);
await autofillService.doAutoFillActiveTab(identityFormPageDetails, true, CipherType.Identity);
expect(autofillService["cipherService"].getAllDecryptedForUrl).toHaveBeenCalled();
expect(autofillService.doAutoFill).toHaveBeenCalledWith({
tab: tab,
cipher: identityCipher,
pageDetails: identityFormPageDetails,
skipLastUsed: true,
skipUsernameOnlyFill: true,
onlyEmptyFields: true,
onlyVisibleFields: true,
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: false,
allowUntrustedIframe: false,
allowUntrustedIframe: true,
allowTotpAutofill: false,
});
});

View File

@ -520,16 +520,30 @@ export default class AutofillService implements AutofillServiceInterface {
return await this.doAutoFillOnTab(pageDetails, tab, fromCommand);
}
// Cipher is a non-login type
const cipher: CipherView = (
(await this.cipherService.getAllDecryptedForUrl(tab.url, [cipherType])) || []
).find(({ type }) => type === cipherType);
let cipher: CipherView;
let cacheKey = "";
if (!cipher || cipher.reprompt !== CipherRepromptType.None) {
if (cipherType === CipherType.Card) {
cacheKey = "cardCiphers";
cipher = await this.cipherService.getNextCardCipher();
} else {
cacheKey = "identityCiphers";
cipher = await this.cipherService.getNextIdentityCipher();
}
if (!cipher || !cacheKey || (cipher.reprompt === CipherRepromptType.Password && !fromCommand)) {
return null;
}
return await this.doAutoFill({
if (await this.isPasswordRepromptRequired(cipher, tab)) {
if (fromCommand) {
this.cipherService.updateLastUsedIndexForUrl(cacheKey);
}
return null;
}
const totpCode = await this.doAutoFill({
tab: tab,
cipher: cipher,
pageDetails: pageDetails,
@ -541,6 +555,12 @@ export default class AutofillService implements AutofillServiceInterface {
allowUntrustedIframe: fromCommand,
allowTotpAutofill: false,
});
if (fromCommand) {
this.cipherService.updateLastUsedIndexForUrl(cacheKey);
}
return totpCode;
}
/**

View File

@ -1,6 +1,7 @@
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@ -47,8 +48,23 @@ export default class CommandsBackground {
case "generate_password":
await this.generatePasswordToClipboard();
break;
case "autofill_login":
await this.autoFillLogin(sender ? sender.tab : null);
case ExtensionCommand.AutofillLogin:
await this.triggerAutofillCommand(
sender ? sender.tab : null,
ExtensionCommand.AutofillCommand,
);
break;
case ExtensionCommand.AutofillCard:
await this.triggerAutofillCommand(
sender ? sender.tab : null,
ExtensionCommand.AutofillCard,
);
break;
case ExtensionCommand.AutofillIdentity:
await this.triggerAutofillCommand(
sender ? sender.tab : null,
ExtensionCommand.AutofillIdentity,
);
break;
case "open_popup":
await this.openPopup();
@ -68,19 +84,27 @@ export default class CommandsBackground {
await this.passwordGenerationService.addHistory(password);
}
private async autoFillLogin(tab?: chrome.tabs.Tab) {
private async triggerAutofillCommand(
tab?: chrome.tabs.Tab,
commandSender?: ExtensionCommandType,
) {
if (!tab) {
tab = await BrowserApi.getTabFromCurrentWindowId();
}
if (tab == null) {
if (tab == null || !commandSender) {
return;
}
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
const retryMessage: LockedVaultPendingNotificationsData = {
commandToRetry: {
message: { command: "autofill_login" },
message: {
command:
commandSender === ExtensionCommand.AutofillCommand
? ExtensionCommand.AutofillLogin
: commandSender,
},
sender: { tab: tab },
},
target: "commands.background",
@ -95,7 +119,7 @@ export default class CommandsBackground {
return;
}
await this.main.collectPageDetailsForContentScript(tab, "autofill_cmd");
await this.main.collectPageDetailsForContentScript(tab, commandSender);
}
private async openPopup() {

View File

@ -2,7 +2,7 @@ import { firstValueFrom, map, mergeMap } from "rxjs";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillOverlayVisibility, ExtensionCommand } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -117,7 +117,7 @@ export default class RuntimeBackground {
case "collectPageDetailsResponse":
switch (msg.sender) {
case "autofiller":
case "autofill_cmd": {
case ExtensionCommand.AutofillCommand: {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
@ -130,14 +130,14 @@ export default class RuntimeBackground {
details: msg.details,
},
],
msg.sender === "autofill_cmd",
msg.sender === ExtensionCommand.AutofillCommand,
);
if (totpCode != null) {
this.platformUtilsService.copyToClipboard(totpCode);
}
break;
}
case "autofill_card": {
case ExtensionCommand.AutofillCard: {
await this.autofillService.doAutoFillActiveTab(
[
{
@ -146,12 +146,12 @@ export default class RuntimeBackground {
details: msg.details,
},
],
false,
msg.sender === ExtensionCommand.AutofillCard,
CipherType.Card,
);
break;
}
case "autofill_identity": {
case ExtensionCommand.AutofillIdentity: {
await this.autofillService.doAutoFillActiveTab(
[
{
@ -160,7 +160,7 @@ export default class RuntimeBackground {
details: msg.details,
},
],
false,
msg.sender === ExtensionCommand.AutofillIdentity,
CipherType.Identity,
);
break;

View File

@ -94,7 +94,13 @@
"suggested_key": {
"default": "Ctrl+Shift+L"
},
"description": "__MSG_commandAutofillDesc__"
"description": "__MSG_commandAutofillLoginDesc__"
},
"autofill_card": {
"description": "__MSG_commandAutofillCardDesc__"
},
"autofill_identity": {
"description": "__MSG_commandAutofillIdentityDesc__"
},
"generate_password": {
"suggested_key": {

View File

@ -99,7 +99,13 @@
"suggested_key": {
"default": "Ctrl+Shift+L"
},
"description": "__MSG_commandAutofillDesc__"
"description": "__MSG_commandAutofillLoginDesc__"
},
"autofill_card": {
"description": "__MSG_commandAutofillCardDesc__"
},
"autofill_identity": {
"description": "__MSG_commandAutofillIdentityDesc__"
},
"generate_password": {
"suggested_key": {

View File

@ -1,3 +1,4 @@
import { ExtensionCommand } from "@bitwarden/common/autofill/constants";
import { ClientType, DeviceType } from "@bitwarden/common/enums";
import {
ClipboardOptions,
@ -298,7 +299,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
autofillCommand = "Cmd+Shift+L";
} else if (this.isFirefox()) {
autofillCommand = (await browser.commands.getAll()).find(
(c) => c.name === "autofill_login",
(c) => c.name === ExtensionCommand.AutofillLogin,
).shortcut;
// Firefox is returning Ctrl instead of Cmd for the modifier key on macOS if
// the command is the default one set on installation.
@ -311,7 +312,9 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
} else {
await new Promise((resolve) =>
chrome.commands.getAll((c) =>
resolve((autofillCommand = c.find((c) => c.name === "autofill_login").shortcut)),
resolve(
(autofillCommand = c.find((c) => c.name === ExtensionCommand.AutofillLogin).shortcut),
),
),
);
}

View File

@ -85,3 +85,17 @@ export const DisablePasswordManagerUris = {
Vivaldi: "vivaldi://settings/autofill",
Unknown: "https://bitwarden.com/help/disable-browser-autofill/",
} as const;
export const ExtensionCommand = {
AutofillCommand: "autofill_cmd",
AutofillCard: "autofill_card",
AutofillIdentity: "autofill_identity",
AutofillLogin: "autofill_login",
OpenAutofillOverlay: "open_autofill_overlay",
GeneratePassword: "generate_password",
OpenPopup: "open_popup",
LockVault: "lock_vault",
NoopCommand: "noop",
} as const;
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];

View File

@ -162,4 +162,6 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
newUserKey: UserKey,
userId: UserId,
) => Promise<CipherWithIdRequest[]>;
getNextCardCipher: () => Promise<CipherView>;
getNextIdentityCipher: () => Promise<CipherView>;
}

View File

@ -500,6 +500,13 @@ export class CipherService implements CipherServiceAbstraction {
});
}
private async getAllDecryptedCiphersOfType(type: CipherType[]): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
return ciphers
.filter((cipher) => cipher.deletedDate == null && type.includes(cipher.type))
.sort((a, b) => this.sortCiphersByLastUsedThenName(a, b));
}
async getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]> {
const response = await this.apiService.getCiphersOrganization(organizationId);
return await this.decryptOrganizationCiphersResponse(response, organizationId);
@ -549,6 +556,36 @@ export class CipherService implements CipherServiceAbstraction {
return this.getCipherForUrl(url, false, false, false);
}
async getNextCardCipher(): Promise<CipherView> {
const cacheKey = "cardCiphers";
if (!this.sortedCiphersCache.isCached(cacheKey)) {
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Card]);
if (!ciphers?.length) {
return null;
}
this.sortedCiphersCache.addCiphers(cacheKey, ciphers);
}
return this.sortedCiphersCache.getNext(cacheKey);
}
async getNextIdentityCipher() {
const cacheKey = "identityCiphers";
if (!this.sortedCiphersCache.isCached(cacheKey)) {
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Identity]);
if (!ciphers?.length) {
return null;
}
this.sortedCiphersCache.addCiphers(cacheKey, ciphers);
}
return this.sortedCiphersCache.getNext(cacheKey);
}
updateLastUsedIndexForUrl(url: string) {
this.sortedCiphersCache.updateLastUsedIndex(url);
}