1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 22:44:11 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Will Martin
2024-08-01 11:30:47 -04:00
committed by GitHub
38 changed files with 506 additions and 127 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"
@@ -1650,6 +1656,10 @@
"message": "Base domain",
"description": "Domain name. Ex. website.com"
},
"baseDomainOptionRecommended": {
"message": "Base domain (recommended)",
"description": "Domain name. Ex. website.com"
},
"domainName": {
"message": "Domain name",
"description": "Domain name. Ex. website.com"
@@ -2774,14 +2784,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

@@ -190,7 +190,6 @@ export type OverlayBackgroundExtensionMessageHandlers = {
}: BackgroundOnMessageHandlerParams) => void;
collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
unlockCompleted: ({ message }: BackgroundMessageParam) => void;
doFullSync: () => void;
addedCipher: () => void;
addEditCipherSubmitted: () => void;
editedCipher: () => void;

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

@@ -2014,7 +2014,6 @@ describe("OverlayBackground", () => {
describe("extension messages that trigger an update of the inline menu ciphers", () => {
const extensionMessages = [
"doFullSync",
"addedCipher",
"addEditCipherSubmitted",
"editedCipher",

View File

@@ -120,7 +120,6 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.triggerDestroyInlineMenuListeners(sender.tab, message.subFrameData.frameId),
collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender),
unlockCompleted: ({ message }) => this.unlockCompleted(message),
doFullSync: () => this.updateOverlayCiphers(),
addedCipher: () => this.updateOverlayCiphers(),
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
editedCipher: () => this.updateOverlayCiphers(),
@@ -273,7 +272,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "")
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
return cipherViews.concat(...this.cardAndIdentityCiphers);
return this.cardAndIdentityCiphers
? cipherViews.concat(...this.cardAndIdentityCiphers)
: cipherViews;
}
/**

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

@@ -399,6 +399,11 @@ describe("AutofillInlineMenuContentService", () => {
});
it("sets the z-index of to a lower value", async () => {
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout(
jest.fn(),
1000,
);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
await waitForIdleCallback();
@@ -411,8 +416,9 @@ describe("AutofillInlineMenuContentService", () => {
});
globalThis.document.elementFromPoint = jest.fn(() => persistentLastChild);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
await waitForIdleCallback();
await autofillInlineMenuContentService["verifyInlineMenuIsNotObscured"](
persistentLastChild,
);
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
overlayElement: AutofillOverlayElement.Button,
@@ -425,8 +431,9 @@ describe("AutofillInlineMenuContentService", () => {
});
globalThis.document.elementFromPoint = jest.fn(() => persistentLastChild);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
await waitForIdleCallback();
await autofillInlineMenuContentService["verifyInlineMenuIsNotObscured"](
persistentLastChild,
);
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
overlayElement: AutofillOverlayElement.List,

View File

@@ -33,6 +33,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private bodyElementMutationObserver: MutationObserver;
private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout;
private lastElementOverrides: WeakMap<Element, number> = new WeakMap();
private readonly customElementDefaultStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
@@ -405,7 +406,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
if (this.lastElementOverrides.get(lastChild) >= 3) {
await this.handlePersistentLastChildOverride(lastChild);
this.handlePersistentLastChildOverride(lastChild);
return;
}
@@ -430,6 +431,26 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
globalThis.document.body.insertBefore(lastChild, this.buttonElement);
};
/**
* Handles the behavior of a persistent child element that is forcing itself to
* the bottom of the body element. This method will ensure that the inline menu
* elements are not obscured by the persistent child element.
*
* @param lastChild - The last child of the body element.
*/
private handlePersistentLastChildOverride(lastChild: Element) {
const lastChildZIndex = parseInt((lastChild as HTMLElement).style.zIndex);
if (lastChildZIndex >= 2147483647) {
(lastChild as HTMLElement).style.zIndex = "2147483646";
}
this.clearPersistentLastChildOverrideTimeout();
this.handlePersistentLastChildOverrideTimeout = globalThis.setTimeout(
() => this.verifyInlineMenuIsNotObscured(lastChild),
500,
);
}
/**
* Verifies if the last child of the body element is overlaying the inline menu elements.
* This is triggered when the last child of the body is being forced by some script to
@@ -437,12 +458,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
*
* @param lastChild - The last child of the body element.
*/
private async handlePersistentLastChildOverride(lastChild: Element) {
const lastChildZIndex = parseInt((lastChild as HTMLElement).style.zIndex);
if (lastChildZIndex >= 2147483647) {
(lastChild as HTMLElement).style.zIndex = "2147483646";
}
private verifyInlineMenuIsNotObscured = async (lastChild: Element) => {
const inlineMenuPosition: InlineMenuPosition = await this.sendExtensionMessage(
"getAutofillInlineMenuPosition",
);
@@ -456,7 +472,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (!!list && this.elementAtCenterOfInlineMenuPosition(list) === lastChild) {
this.closeInlineMenu();
}
}
};
/**
* Returns the element present at the center of the inline menu position.
@@ -470,6 +486,16 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
);
}
/**
* Clears the timeout that is used to verify that the last child of the body element
* is not overlaying the inline menu elements.
*/
private clearPersistentLastChildOverrideTimeout() {
if (this.handlePersistentLastChildOverrideTimeout) {
globalThis.clearTimeout(this.handlePersistentLastChildOverrideTimeout);
}
}
/**
* Identifies if the mutation observer is triggering excessive iterations.
* Will trigger a blur of the most recently focused field and remove the
@@ -503,5 +529,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
*/
destroy() {
this.closeInlineMenu();
this.clearPersistentLastChildOverrideTimeout();
}
}

View File

@@ -377,6 +377,21 @@ describe("AutofillInlineMenuIframeService", () => {
autofillInlineMenuIframeService["ariaAlertElement"],
);
});
it("resets the fade in timeout if it is set", () => {
autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn, 100);
const styles = { top: "100px", left: "100px" };
jest.spyOn(autofillInlineMenuIframeService as any, "handleFadeInInlineMenuIframe");
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles,
});
expect(
autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"],
).toHaveBeenCalled();
});
});
it("updates the visibility of the iframe", () => {

View File

@@ -260,10 +260,13 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
return;
}
const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position;
this.updateElementStyles(this.iframe, styles);
if (this.fadeInTimeout) {
this.handleFadeInInlineMenuIframe();
}
this.updateElementStyles(this.iframe, position);
this.announceAriaAlert();
}
@@ -320,10 +323,10 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
*/
private handleFadeInInlineMenuIframe() {
this.clearFadeInTimeout();
this.fadeInTimeout = globalThis.setTimeout(
() => this.updateElementStyles(this.iframe, { display: "block", opacity: "1" }),
10,
);
this.fadeInTimeout = globalThis.setTimeout(() => {
this.updateElementStyles(this.iframe, { display: "block", opacity: "1" });
this.clearFadeInTimeout();
}, 10);
}
/**

View File

@@ -84,7 +84,7 @@ export class AutofillV1Component implements OnInit {
{ name: i18nService.t("fiveMinutes"), value: 300 },
];
this.uriMatchOptions = [
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("baseDomainOptionRecommended"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("host"), value: UriMatchStrategy.Host },
{ name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
{ name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
@@ -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

@@ -8,7 +8,7 @@
<div class="tw-bg-background-alt tw-p-2">
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "autofillSuggestionsSectionTitle" | i18n }}</h2>
<h2 bitTypography="h6">{{ "autofillSuggestionsSectionTitle" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control>
@@ -87,7 +87,7 @@
/>
<bit-label for="showCardsSuggestions">{{ "showCardsInVaultView" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<bit-form-control disableMargin>
<input
bitCheckbox
id="showIdentitiesSuggestions"
@@ -103,11 +103,11 @@
</bit-section>
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "autofillKeyboardShortcutSectionTitle" | i18n }}</h2>
<h2 bitTypography="h6">{{ "autofillKeyboardShortcutSectionTitle" | i18n }}</h2>
</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>
@@ -122,7 +122,7 @@
</bit-section>
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}</h2>
<h2 bitTypography="h6">{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-hint class="tw-mb-6 tw-text-sm">
@@ -148,7 +148,7 @@
/>
<bit-label for="autofillOnPageLoad">{{ "enableAutoFillOnPageLoad" | i18n }}</bit-label>
</bit-form-control>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label for="defaultAutofill">{{ "defaultAutoFillOnPageLoad" | i18n }}</bit-label>
<select
bitInput
@@ -159,7 +159,7 @@
>
<option
*ngFor="let o of autofillOnPageLoadOptions"
[value]="o.value"
[ngValue]="o.value"
[label]="o.name"
></option>
</select>
@@ -171,7 +171,7 @@
</bit-section>
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "additionalOptions" | i18n }}</h2>
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control>
@@ -206,14 +206,14 @@
<option
*ngFor="let o of clearClipboardOptions"
[label]="o.name"
[value]="o.value"
[ngValue]="o.value"
></option>
</select>
<bit-hint class="tw-text-sm" id="clearClipboardHelp">
{{ "clearClipboardDesc" | i18n }}
</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label for="defaultUriMatch">{{ "defaultUriMatchDetection" | i18n }}</bit-label>
<select
aria-describedby="defaultUriMatchHelp"
@@ -222,7 +222,7 @@
(change)="saveDefaultUriMatch()"
[(ngModel)]="defaultUriMatch"
>
<option *ngFor="let o of uriMatchOptions" [label]="o.name" [value]="o.value"></option>
<option *ngFor="let o of uriMatchOptions" [label]="o.name" [ngValue]="o.value"></option>
</select>
<bit-hint class="tw-text-sm" id="defaultUriMatchHelp">
{{ "defaultUriMatchDetectionDesc" | i18n }}

View File

@@ -126,7 +126,7 @@ export class AutofillComponent implements OnInit {
{ name: i18nService.t("fiveMinutes"), value: ClearClipboardDelay.FiveMinutes },
];
this.uriMatchOptions = [
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("baseDomainOptionRecommended"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("host"), value: UriMatchStrategy.Host },
{ name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
{ name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
@@ -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

@@ -13,7 +13,7 @@
</p>
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "domainsTitle" | i18n }}</h2>
<h2 bitTypography="h6">{{ "domainsTitle" | i18n }}</h2>
<span bitTypography="body2" slot="end">{{ excludedDomainsState?.length || 0 }}</span>
</bit-section-header>

View File

@@ -8,7 +8,7 @@
<div class="tw-bg-background-alt tw-p-2">
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "vaultSaveOptionsTitle" | i18n }}</h2>
<h2 bitTypography="h6">{{ "vaultSaveOptionsTitle" | i18n }}</h2>
</bit-section-header>
<bit-card>
<div>

View File

@@ -393,6 +393,7 @@ export class IdentityAutoFillConstants {
"address-line-2",
"addr-2",
"street-2",
"address-ext",
];
static readonly Address3FieldNames: string[] = [
@@ -409,6 +410,7 @@ export class IdentityAutoFillConstants {
"zip-code",
"postal-code",
"post-code",
"postcode",
"address-zip",
"address-postal",
"address-code",

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

@@ -16,7 +16,7 @@ export class InlineMenuFieldQualificationService
implements InlineMenuFieldQualificationServiceInterface
{
private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames);
private excludedAutofillLoginTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes);
private excludedAutofillFieldTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes);
private usernameFieldTypes = new Set(["text", "email", "number", "tel"]);
private usernameAutocompleteValue = "username";
private emailAutocompleteValue = "email";
@@ -244,6 +244,10 @@ export class InlineMenuFieldQualificationService
* @param pageDetails - The details of the page that the field is on.
*/
isFieldForAccountCreationForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean {
if (this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)) {
return false;
}
if (!this.isUsernameField(field) && !this.isPasswordField(field)) {
return false;
}
@@ -286,6 +290,10 @@ export class InlineMenuFieldQualificationService
* @param _pageDetails - Currently unused, will likely be required in the future
*/
isFieldForIdentityForm(field: AutofillField, _pageDetails: AutofillPageDetails): boolean {
if (this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)) {
return false;
}
if (this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues)) {
return true;
}
@@ -658,7 +666,14 @@ export class InlineMenuFieldQualificationService
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.Address1FieldNames, false)
this.keywordsFoundInFieldData(
field,
[
...IdentityAutoFillConstants.AddressFieldNames,
...IdentityAutoFillConstants.Address1FieldNames,
],
false,
)
);
};
@@ -722,7 +737,7 @@ export class InlineMenuFieldQualificationService
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.AddressFieldNames, false)
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false)
);
};
@@ -833,7 +848,7 @@ export class InlineMenuFieldQualificationService
isUsernameField = (field: AutofillField): boolean => {
if (
!this.usernameFieldTypes.has(field.type) ||
this.isExcludedFieldType(field, this.excludedAutofillLoginTypesSet)
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)
) {
return false;
}
@@ -852,7 +867,7 @@ export class InlineMenuFieldQualificationService
}
return (
!this.isExcludedFieldType(field, this.excludedAutofillLoginTypesSet) &&
!this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) &&
this.keywordsFoundInFieldData(field, AutoFillConstants.EmailFieldNames)
);
};
@@ -898,7 +913,7 @@ export class InlineMenuFieldQualificationService
const isInputPasswordType = field.type === "password";
if (
(!isInputPasswordType &&
this.isExcludedFieldType(field, this.excludedAutofillLoginTypesSet)) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)) ||
this.fieldHasDisqualifyingAttributeValue(field)
) {
return false;

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

@@ -1245,6 +1245,13 @@ export default class MainBackground {
}
}
async updateOverlayCiphers() {
// overlayBackground null in popup only contexts
if (this.overlayBackground) {
await this.overlayBackground.updateOverlayCiphers();
}
}
/**
* Switch accounts to indicated userId -- null is no active user
*/
@@ -1273,7 +1280,7 @@ export default class MainBackground {
if (userId == null) {
await this.refreshBadge();
await this.refreshMenu();
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts
await this.updateOverlayCiphers();
this.messagingService.send("goHome");
return;
}
@@ -1296,7 +1303,7 @@ export default class MainBackground {
this.messagingService.send("unlocked", { userId: userId });
await this.refreshBadge();
await this.refreshMenu();
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts
await this.updateOverlayCiphers();
await this.syncService.fullSync(false);
}
} finally {
@@ -1480,7 +1487,17 @@ export default class MainBackground {
* Temporary solution to handle initialization of the overlay background behind a feature flag.
* Will be reverted to instantiation within the constructor once the feature flag is removed.
*/
private async initOverlayAndTabsBackground() {
async initOverlayAndTabsBackground() {
if (
this.popupOnlyContext ||
this.overlayBackground ||
this.tabsBackground ||
(await firstValueFrom(this.authService.activeAccountStatus$)) ===
AuthenticationStatus.LoggedOut
) {
return;
}
const inlineMenuPositioningImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);

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;
@@ -200,6 +200,7 @@ export default class RuntimeBackground {
let item: LockedVaultPendingNotificationsData;
if (msg.command === "loggedIn") {
await this.main.initOverlayAndTabsBackground();
await this.sendBwInstalledMessageToVault();
await this.autofillService.reloadAutofillScripts();
}
@@ -246,6 +247,7 @@ export default class RuntimeBackground {
await this.main.refreshMenu();
}, 2000);
await this.configService.ensureConfigFetched();
await this.main.updateOverlayCiphers();
}
break;
case "openPopup":

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

@@ -40,6 +40,7 @@ export class LockComponent extends BaseLockComponent {
protected biometricReady = false;
private biometricAsked = false;
private autoPromptBiometric = false;
private timerId: any;
constructor(
masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -135,11 +136,18 @@ export class LockComponent extends BaseLockComponent {
});
});
this.messagingService.send("getWindowIsFocused");
// start background listener until destroyed on interval
this.timerId = setInterval(async () => {
this.supportsBiometric = await this.platformUtilsService.supportsBiometric();
this.biometricReady = await this.canUseBiometric();
}, 1000);
}
ngOnDestroy() {
super.ngOnDestroy();
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
clearInterval(this.timerId);
}
onWindowHidden() {

View File

@@ -39,6 +39,7 @@ import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.
import { DesktopSettingsService } from "./platform/services/desktop-settings.service";
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
import { ElectronStorageService } from "./platform/services/electron-storage.service";
import { EphemeralValueStorageService } from "./platform/services/ephemeral-value-storage.main.service";
import { I18nMainService } from "./platform/services/i18n.main.service";
import { ElectronMainMessagingService } from "./services/electron-main-messaging.service";
import { isMacAppStore } from "./utils";
@@ -224,6 +225,8 @@ export class Main {
this.clipboardMain = new ClipboardMain();
this.clipboardMain.init();
new EphemeralValueStorageService();
}
bootstrap() {

View File

@@ -99,6 +99,14 @@ const crypto = {
ipcRenderer.invoke("crypto.argon2", { password, salt, iterations, memory, parallelism }),
};
const ephemeralStore = {
setEphemeralValue: (key: string, value: string): Promise<void> =>
ipcRenderer.invoke("setEphemeralValue", { key, value }),
getEphemeralValue: (key: string): Promise<string> => ipcRenderer.invoke("getEphemeralValue", key),
removeEphemeralValue: (key: string): Promise<void> =>
ipcRenderer.invoke("deleteEphemeralValue", key),
};
export default {
versions: {
app: (): Promise<string> => ipcRenderer.invoke("appVersion"),
@@ -156,6 +164,7 @@ export default {
powermonitor,
nativeMessaging,
crypto,
ephemeralStore,
};
function deviceType(): DeviceType {

View File

@@ -0,0 +1,21 @@
import { ipcMain } from "electron";
/**
* The ephemeral value store holds values that should be accessible to the renderer past a process reload.
* In the current state, this store must not contain any keys that can decrypt a vault by themselves.
*/
export class EphemeralValueStorageService {
private ephemeralValues = new Map<string, string>();
constructor() {
ipcMain.handle("setEphemeralValue", async (event, { key, value }) => {
this.ephemeralValues.set(key, value);
});
ipcMain.handle("getEphemeralValue", async (event, key: string) => {
return this.ephemeralValues.get(key);
});
ipcMain.handle("deleteEphemeralValue", async (event, key: string) => {
this.ephemeralValues.delete(key);
});
}
}

View File

@@ -3,7 +3,6 @@ import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -30,8 +29,6 @@ const HashAlgorithmForAsymmetricEncryption = "sha1";
@Injectable()
export class NativeMessagingService {
private sharedSecrets = new Map<string, SymmetricCryptoKey>();
constructor(
private cryptoFunctionService: CryptoFunctionService,
private cryptoService: CryptoService,
@@ -104,7 +101,7 @@ export class NativeMessagingService {
return;
}
if (this.sharedSecrets.get(appId) == null) {
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
ipc.platform.nativeMessaging.sendMessage({
command: "invalidateEncryption",
appId: appId,
@@ -115,7 +112,7 @@ export class NativeMessagingService {
const message: LegacyMessage = JSON.parse(
await this.cryptoService.decryptToUtf8(
rawMessage as EncString,
this.sharedSecrets.get(appId),
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
),
);
@@ -147,11 +144,6 @@ export class NativeMessagingService {
return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId);
}
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus !== AuthenticationStatus.Unlocked) {
return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId);
}
const biometricUnlockPromise =
message.userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
@@ -185,6 +177,7 @@ export class NativeMessagingService {
},
appId,
);
await ipc.platform.reloadProcess();
} else {
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
}
@@ -205,7 +198,7 @@ export class NativeMessagingService {
const encrypted = await this.cryptoService.encrypt(
JSON.stringify(message),
this.sharedSecrets.get(appId),
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
);
ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted });
@@ -213,7 +206,10 @@ export class NativeMessagingService {
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
const secret = await this.cryptoFunctionService.randomBytes(64);
this.sharedSecrets.set(appId, new SymmetricCryptoKey(secret));
await ipc.platform.ephemeralStore.setEphemeralValue(
appId,
new SymmetricCryptoKey(secret).keyB64,
);
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
secret,

View File

@@ -92,7 +92,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected canUseSecretsManager$: Observable<boolean>;
protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$(
protected enableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$(
FeatureFlag.EnableUpgradePasswordManagerSub,
);
@@ -476,10 +476,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.organization.productTierType === ProductTierType.TeamsStarter ||
this.organization.productTierType === ProductTierType.Families)
) {
const EnableUpgradePasswordManagerSub = await firstValueFrom(
this.EnableUpgradePasswordManagerSub$,
const enableUpgradePasswordManagerSub = await firstValueFrom(
this.enableUpgradePasswordManagerSub$,
);
if (EnableUpgradePasswordManagerSub) {
if (enableUpgradePasswordManagerSub) {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: this.organization.id,

View File

@@ -86,7 +86,10 @@ import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { openIndividualVaultCollectionsDialog } from "./collections.component";
import {
CollectionsDialogResult,
openIndividualVaultCollectionsDialog,
} from "./collections.component";
import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component";
import { ShareComponent } from "./share.component";
import { VaultBannersComponent } from "./vault-banners/vault-banners.component";
@@ -573,7 +576,14 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async editCipherCollections(cipher: CipherView) {
openIndividualVaultCollectionsDialog(this.dialogService, { data: { cipherId: cipher.id } });
const dialog = openIndividualVaultCollectionsDialog(this.dialogService, {
data: { cipherId: cipher.id },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionsDialogResult.Saved) {
this.refresh();
}
}
async addCipher() {

View File

@@ -0,0 +1,90 @@
import { Component, ElementRef, ViewChild } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { CopyClickDirective } from "./copy-click.directive";
@Component({
template: `
<button appCopyClick="no toast shown" #noToast></button>
<button appCopyClick="info toast shown" showToast="info" #infoToast></button>
<button appCopyClick="success toast shown" showToast #successToast></button>
`,
})
class TestCopyClickComponent {
@ViewChild("noToast") noToastButton: ElementRef<HTMLButtonElement>;
@ViewChild("infoToast") infoToastButton: ElementRef<HTMLButtonElement>;
@ViewChild("successToast") successToastButton: ElementRef<HTMLButtonElement>;
}
describe("CopyClickDirective", () => {
let fixture: ComponentFixture<TestCopyClickComponent>;
const copyToClipboard = jest.fn();
const showToast = jest.fn();
beforeEach(async () => {
copyToClipboard.mockClear();
showToast.mockClear();
await TestBed.configureTestingModule({
declarations: [CopyClickDirective, TestCopyClickComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: ToastService, useValue: { showToast } },
],
}).compileComponents();
fixture = TestBed.createComponent(TestCopyClickComponent);
fixture.detectChanges();
});
it("copies the the value for all variants of toasts ", () => {
const noToastButton = fixture.componentInstance.noToastButton.nativeElement;
noToastButton.click();
expect(copyToClipboard).toHaveBeenCalledWith("no toast shown");
const infoToastButton = fixture.componentInstance.infoToastButton.nativeElement;
infoToastButton.click();
expect(copyToClipboard).toHaveBeenCalledWith("info toast shown");
const successToastButton = fixture.componentInstance.successToastButton.nativeElement;
successToastButton.click();
expect(copyToClipboard).toHaveBeenCalledWith("success toast shown");
});
it("does not show a toast when showToast is not present", () => {
const noToastButton = fixture.componentInstance.noToastButton.nativeElement;
noToastButton.click();
expect(showToast).not.toHaveBeenCalled();
});
it("shows a success toast when showToast is present", () => {
const successToastButton = fixture.componentInstance.successToastButton.nativeElement;
successToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "success",
});
});
it("shows the toast variant when set with showToast", () => {
const infoToastButton = fixture.componentInstance.infoToastButton.nativeElement;
infoToastButton.click();
expect(showToast).toHaveBeenCalledWith({
message: "copySuccessful",
title: null,
variant: "info",
});
});
});

View File

@@ -1,14 +1,17 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Directive, HostListener, Input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { ToastVariant } from "@bitwarden/components/src/toast/toast.component";
@Directive({
selector: "[appCopyClick]",
})
export class CopyClickDirective {
private _showToast = false;
private toastVariant: ToastVariant = "success";
constructor(
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
@@ -16,14 +19,36 @@ export class CopyClickDirective {
) {}
@Input("appCopyClick") valueToCopy = "";
@Input({ transform: coerceBooleanProperty }) showToast?: boolean;
/**
* When set without a value, a success toast will be shown when the value is copied
* @example
* ```html
* <app-component [appCopyClick]="value to copy" showToast/></app-component>
* ```
* When set with a value, a toast with the specified variant will be shown when the value is copied
*
* @example
* ```html
* <app-component [appCopyClick]="value to copy" showToast="info"/></app-component>
* ```
*/
@Input() set showToast(value: ToastVariant | "") {
// When the `showToast` is set without a value, an empty string will be passed
if (value === "") {
this._showToast = true;
} else {
this._showToast = true;
this.toastVariant = value;
}
}
@HostListener("click") onClick() {
this.platformUtilsService.copyToClipboard(this.valueToCopy);
if (this.showToast) {
if (this._showToast) {
this.toastService.showToast({
variant: "info",
variant: this.toastVariant,
title: null,
message: this.i18nService.t("copySuccessful"),
});

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);
}