diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index fa42839ce79..94664485a92 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -396,7 +396,7 @@ }, "generator": { "message": "Generator", - "description": "Short for 'Password Generator'." + "description": "Short for 'credential generator'." }, "passGenInfo": { "message": "Automatically generate strong, unique passwords for your logins." @@ -2516,7 +2516,25 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendAvailability": { + "sendExpiresInHoursSingle": { + "message": "The Send will be available to anyone with the link for the next 1 hour.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendExpiresInHours": { + "message": "The Send will be available to anyone with the link for the next $HOURS$ hours.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "sendExpiresInDaysSingle": { + "message": "The Send will be available to anyone with the link for the next 1 day.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendExpiresInDays": { "message": "The Send will be available to anyone with the link for the next $DAYS$ days.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { @@ -3680,6 +3698,9 @@ "noMatchingLoginsForSite": { "message": "No matching logins for this site" }, + "searchSavePasskeyNewLogin": { + "message": "Search or save passkey as new login" + }, "confirm": { "message": "Confirm" }, diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index d694438c00f..8bac8ea6913 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -24,6 +25,7 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou describe("OverlayNotificationsBackground", () => { let logService: MockProxy; + let getFeatureFlagMock$: BehaviorSubject; let configService: MockProxy; let notificationBackground: NotificationBackground; let getEnableChangedPasswordPromptSpy: jest.SpyInstance; @@ -33,7 +35,10 @@ describe("OverlayNotificationsBackground", () => { beforeEach(async () => { jest.useFakeTimers(); logService = mock(); - configService = mock(); + getFeatureFlagMock$ = new BehaviorSubject(true); + configService = mock({ + getFeatureFlag$: jest.fn().mockReturnValue(getFeatureFlagMock$), + }); notificationBackground = mock(); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") @@ -164,8 +169,17 @@ describe("OverlayNotificationsBackground", () => { }); describe("storing the modified login form data", () => { + const pageDetails = mock({ fields: [mock()] }); const sender = mock({ tab: { id: 1 } }); + beforeEach(async () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + }); + it("stores the modified login cipher form data", async () => { sendMockExtensionMessage( { @@ -349,8 +363,14 @@ describe("OverlayNotificationsBackground", () => { describe("web requests that trigger notifications", () => { const requestId = "123345"; + const pageDetails = mock({ fields: [mock()] }); beforeEach(async () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); sendMockExtensionMessage( { command: "formFieldSubmitted", @@ -446,6 +466,11 @@ describe("OverlayNotificationsBackground", () => { it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { sender.tab = mock({ id: 4 }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); sendMockExtensionMessage( { command: "formFieldSubmitted", diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index ca8f05b77dc..5ea3e8b8d6b 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,4 +1,5 @@ -import { Subject, switchMap, timer } from "rxjs"; +import { startWith, Subject, Subscription, switchMap, timer } from "rxjs"; +import { pairwise } from "rxjs/operators"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -23,7 +24,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map(); private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); + private featureFlagState$: Subscription; private clearLoginCipherFormDataSubject: Subject = new Subject(); + private notificationFallbackTimeout: number | NodeJS.Timeout | null; private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender), @@ -41,19 +44,35 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * Initialize the overlay notifications background service. */ async init() { - const featureFlagActive = await this.configService.getFeatureFlag( - FeatureFlag.NotificationBarAddLoginImprovements, - ); - if (!featureFlagActive) { - return; - } - - this.setupExtensionListeners(); + this.featureFlagState$ = this.configService + .getFeatureFlag$(FeatureFlag.NotificationBarAddLoginImprovements) + .pipe(startWith(undefined), pairwise()) + .subscribe(([prev, current]) => this.handleInitFeatureFlagChange(prev, current)); this.clearLoginCipherFormDataSubject .pipe(switchMap(() => timer(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION))) .subscribe(() => this.modifyLoginCipherFormData.clear()); } + /** + * Handles enabling/disabling the extension listeners that trigger the + * overlay notifications based on the feature flag state. + * + * @param previousValue - The previous value of the feature flag + * @param currentValue - The current value of the feature flag + */ + private handleInitFeatureFlagChange = (previousValue: boolean, currentValue: boolean) => { + if (previousValue === currentValue) { + return; + } + + if (currentValue) { + this.setupExtensionListeners(); + return; + } + + this.removeExtensionListeners(); + }; + /** * Handles the response from the content script with the page details. Triggers an initialization * of the add login or change password notification if the conditions are met. @@ -126,6 +145,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) => { + if (!this.websiteOriginsWithFields.has(sender.tab.id)) { + return; + } + const { uri, username, password, newPassword } = message; if (!username && !password && !newPassword) { return; @@ -142,8 +165,29 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg } this.modifyLoginCipherFormData.set(sender.tab.id, formData); + + this.clearNotificationFallbackTimeout(); + this.notificationFallbackTimeout = setTimeout( + () => + this.setupNotificationInitTrigger( + sender.tab.id, + "", + this.modifyLoginCipherFormData.get(sender.tab.id), + ).catch((error) => this.logService.error(error)), + 1500, + ); }; + /** + * Clears the timeout used when triggering a notification on click of the submit button. + */ + private clearNotificationFallbackTimeout() { + if (this.notificationFallbackTimeout) { + clearTimeout(this.notificationFallbackTimeout); + this.notificationFallbackTimeout = null; + } + } + /** * Determines if the sender of the message is from an excluded domain. This is used to prevent the * add login or change password notification from being triggered on the user's vault domain or @@ -306,12 +350,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => { if ( this.requestHostIsInvalid(details) || - isInvalidResponseStatusCode(details.statusCode) || !this.activeFormSubmissionRequests.has(details.requestId) ) { return; } + if (isInvalidResponseStatusCode(details.statusCode)) { + this.clearNotificationFallbackTimeout(); + return; + } + const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); if (!modifyLoginData) { return; @@ -335,6 +383,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg requestId: string, modifyLoginData: ModifyLoginCipherFormData, ) => { + this.clearNotificationFallbackTimeout(); + const tab = await BrowserApi.getTab(tabId); if (tab.status !== "complete") { await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); @@ -463,11 +513,20 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * Sets up the listeners for the extension messages and the tab events. */ private setupExtensionListeners() { - BrowserApi.messageListener("overlay-notifications", this.handleExtensionMessage); + BrowserApi.addListener(chrome.runtime.onMessage, this.handleExtensionMessage); chrome.tabs.onRemoved.addListener(this.handleTabRemoved); chrome.tabs.onUpdated.addListener(this.handleTabUpdated); } + /** + * Removes the listeners for the extension messages and the tab events. + */ + private removeExtensionListeners() { + BrowserApi.removeListener(chrome.runtime.onMessage, this.handleExtensionMessage); + chrome.tabs.onRemoved.removeListener(this.handleTabRemoved); + chrome.tabs.onUpdated.removeListener(this.handleTabUpdated); + } + /** * Handles messages that are sent to the extension background. * diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 653d31ca52c..b45a4a25485 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1484,9 +1484,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { } /** - * Gets the user's authentication status from the auth service. If the user's authentication - * status has changed, the inline menu button's authentication status will be updated - * and the inline menu list's ciphers will be updated. + * Gets the user's authentication status from the auth service. */ private async getAuthStatus() { return await firstValueFrom(this.authService.activeAccountStatus$); diff --git a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts index d77a60d3c7b..6ad069ad56e 100644 --- a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts @@ -45,7 +45,6 @@ type Fido2BackgroundExtensionMessageHandlers = { interface Fido2Background { init(): void; - injectFido2ContentScriptsInAllTabs(): Promise; } export { diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 23d0292e188..99ed4619954 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -1,6 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; import { @@ -59,6 +61,8 @@ describe("Fido2Background", () => { let scriptInjectorServiceMock!: MockProxy; let configServiceMock!: MockProxy; let enablePasskeysMock$!: BehaviorSubject; + let activeAccountStatusMock$: BehaviorSubject; + let authServiceMock!: MockProxy; let fido2Background!: Fido2Background; beforeEach(() => { @@ -81,6 +85,9 @@ describe("Fido2Background", () => { vaultSettingsService.enablePasskeys$ = enablePasskeysMock$; fido2ActiveRequestManager = mock(); fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true); + activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); + authServiceMock = mock(); + authServiceMock.activeAccountStatus$ = activeAccountStatusMock$; fido2Background = new Fido2Background( logService, fido2ActiveRequestManager, @@ -88,6 +95,7 @@ describe("Fido2Background", () => { vaultSettingsService, scriptInjectorServiceMock, configServiceMock, + authServiceMock, ); fido2Background["abortManager"] = abortManagerMock; abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) => @@ -101,55 +109,31 @@ describe("Fido2Background", () => { jest.clearAllMocks(); }); - describe("injectFido2ContentScriptsInAllTabs", () => { - it("does not inject any FIDO2 content scripts when no tabs have a secure url protocol", async () => { - const insecureTab = mock({ id: 789, url: "http://example.com" }); - tabsQuerySpy.mockResolvedValueOnce([insecureTab]); + describe("handleAuthStatusUpdate", () => { + let updateContentScriptRegistrationSpy: jest.SpyInstance; - await fido2Background.injectFido2ContentScriptsInAllTabs(); - - expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled(); + beforeEach(() => { + updateContentScriptRegistrationSpy = jest + .spyOn(fido2Background as any, "updateContentScriptRegistration") + .mockImplementation(); }); - it("only injects the FIDO2 content script into tabs that contain a secure url protocol", async () => { - const secondTabMock = mock({ id: 456, url: "https://example.com" }); - const insecureTab = mock({ id: 789, url: "http://example.com" }); - const noUrlTab = mock({ id: 101, url: undefined }); - tabsQuerySpy.mockResolvedValueOnce([tabMock, secondTabMock, insecureTab, noUrlTab]); + it("skips triggering the passkeys settings update if the user is logged out", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); - await fido2Background.injectFido2ContentScriptsInAllTabs(); + fido2Background.init(); await flushPromises(); - expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ - tabId: tabMock.id, - injectDetails: contentScriptDetails, - }); - expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ - tabId: secondTabMock.id, - injectDetails: contentScriptDetails, - }); - expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({ - tabId: insecureTab.id, - injectDetails: contentScriptDetails, - }); - expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({ - tabId: noUrlTab.id, - injectDetails: contentScriptDetails, - }); + expect(updateContentScriptRegistrationSpy).not.toHaveBeenCalled(); }); - it("injects the `page-script.js` content script into the provided tab", async () => { - tabsQuerySpy.mockResolvedValueOnce([tabMock]); + it("triggers the passkeys setting update if the user is logged in", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - await fido2Background.injectFido2ContentScriptsInAllTabs(); + fido2Background.init(); await flushPromises(); - expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ - tabId: tabMock.id, - injectDetails: sharedScriptInjectionDetails, - mv2Details: { file: Fido2ContentScript.PageScriptAppend }, - mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" }, - }); + expect(updateContentScriptRegistrationSpy).toHaveBeenCalled(); }); }); @@ -157,6 +141,7 @@ describe("Fido2Background", () => { let portMock!: MockProxy; beforeEach(() => { + jest.spyOn(fido2Background as any, "handleAuthStatusUpdate").mockImplementation(); fido2Background.init(); jest.spyOn(BrowserApi, "registerContentScriptsMv2"); jest.spyOn(BrowserApi, "registerContentScriptsMv3"); @@ -168,6 +153,15 @@ describe("Fido2Background", () => { tabsQuerySpy.mockResolvedValue([tabMock]); }); + it("skips handling the passkey update if the user is logged out", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + + enablePasskeysMock$.next(true); + + expect(portMock.disconnect).not.toHaveBeenCalled(); + expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled(); + }); + it("does not destroy and re-inject the content scripts when triggering `handleEnablePasskeysUpdate` with an undefined currentEnablePasskeysSetting property", async () => { await flushPromises(); @@ -421,6 +415,7 @@ describe("Fido2Background", () => { let portMock!: MockProxy; beforeEach(() => { + jest.spyOn(fido2Background as any, "handleAuthStatusUpdate").mockImplementation(); fido2Background.init(); portMock = createPortSpyMock(Fido2PortName.InjectedScript); triggerRuntimeOnConnectEvent(portMock); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index a9d1b314770..810cdf74657 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -1,6 +1,8 @@ -import { firstValueFrom, startWith } from "rxjs"; +import { firstValueFrom, startWith, Subscription } from "rxjs"; import { pairwise } from "rxjs/operators"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; @@ -29,6 +31,7 @@ import { } from "./abstractions/fido2.background"; export class Fido2Background implements Fido2BackgroundInterface { + private currentAuthStatus$: Subscription; private abortManager = new AbortManager(); private fido2ContentScriptPortsSet = new Set(); private registeredContentScripts: browser.contentScripts.RegisteredContentScript; @@ -55,6 +58,7 @@ export class Fido2Background implements Fido2BackgroundInterface { private vaultSettingsService: VaultSettingsService, private scriptInjectorService: ScriptInjectorService, private configService: ConfigService, + private authService: AuthService, ) {} /** @@ -68,12 +72,32 @@ export class Fido2Background implements Fido2BackgroundInterface { this.vaultSettingsService.enablePasskeys$ .pipe(startWith(undefined), pairwise()) .subscribe(([previous, current]) => this.handleEnablePasskeysUpdate(previous, current)); + this.currentAuthStatus$ = this.authService.activeAccountStatus$ + .pipe(startWith(undefined), pairwise()) + .subscribe(([_previous, current]) => this.handleAuthStatusUpdate(current)); + } + + /** + * Handles initializing the FIDO2 content scripts based on the current + * authentication status. We only want to inject the FIDO2 content scripts + * if the user is logged in. + * + * @param authStatus - The current authentication status. + */ + private async handleAuthStatusUpdate(authStatus: AuthenticationStatus) { + if (authStatus === AuthenticationStatus.LoggedOut) { + return; + } + + const enablePasskeys = await this.isPasskeySettingEnabled(); + await this.handleEnablePasskeysUpdate(enablePasskeys, enablePasskeys); + this.currentAuthStatus$.unsubscribe(); } /** * Injects the FIDO2 content and page script into all existing browser tabs. */ - async injectFido2ContentScriptsInAllTabs() { + private async injectFido2ContentScriptsInAllTabs() { const tabs = await BrowserApi.tabsQuery({}); for (let index = 0; index < tabs.length; index++) { @@ -85,6 +109,13 @@ export class Fido2Background implements Fido2BackgroundInterface { } } + /** + * Gets the user's authentication status from the auth service. + */ + private async getAuthStatus() { + return await firstValueFrom(this.authService.activeAccountStatus$); + } + /** * Handles reacting to the enablePasskeys setting being updated. If the setting * is enabled, the FIDO2 content scripts are injected into all tabs. If the setting @@ -98,13 +129,17 @@ export class Fido2Background implements Fido2BackgroundInterface { previousEnablePasskeysSetting: boolean, enablePasskeys: boolean, ) { - this.fido2ActiveRequestManager.removeAllActiveRequests(); - await this.updateContentScriptRegistration(); + if ((await this.getAuthStatus()) === AuthenticationStatus.LoggedOut) { + return; + } if (previousEnablePasskeysSetting === undefined) { return; } + this.fido2ActiveRequestManager.removeAllActiveRequests(); + await this.updateContentScriptRegistration(); + this.destroyLoadedFido2ContentScripts(); if (enablePasskeys) { void this.injectFido2ContentScriptsInAllTabs(); diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.ts index dd5f33dffb0..f835d2f175b 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.ts @@ -9,6 +9,7 @@ const script = globalContext.document.createElement("script"); script.src = chrome.runtime.getURL("content/fido2-page-script.js"); + script.async = false; const scriptInsertionPoint = globalContext.document.head || globalContext.document.documentElement; diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 2ada31fdfe2..775bc76266d 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -9,6 +9,7 @@ const script = globalContext.document.createElement("script"); script.src = chrome.runtime.getURL("content/fido2-page-script.js"); + script.async = false; // We are ensuring that the script injection is delayed in the event that we are loading // within an iframe element. This prevents an issue with web mail clients that load content diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index c44c263dd23..dfc2bba681a 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -4,6 +4,12 @@ import { MessageType } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; (function (globalContext) { + if (globalContext.document.currentScript) { + globalContext.document.currentScript.parentNode.removeChild( + globalContext.document.currentScript, + ); + } + const shouldExecuteContentScript = globalContext.document.contentType === "text/html" && (globalContext.document.location.protocol === "https:" || diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.html b/apps/browser/src/autofill/popup/fido2/fido2.component.html index 00cd55d31b5..dc9f3ff83ff 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.html +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.html @@ -50,7 +50,7 @@ {{ "noMatchingLoginsForSite" | i18n }} - Search or save passkey as new login + {{ "searchSavePasskeyNewLogin" | i18n }} diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index bcc4d2e2ccb..24186ad4275 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { MockProxy, mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -13,7 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -26,7 +26,6 @@ import { SendCreatedComponent } from "./send-created.component"; describe("SendCreatedComponent", () => { let component: SendCreatedComponent; let fixture: ComponentFixture; - let i18nService: MockProxy; let platformUtilsService: MockProxy; let sendService: MockProxy; let toastService: MockProxy; @@ -36,17 +35,10 @@ describe("SendCreatedComponent", () => { let router: MockProxy; const sendId = "test-send-id"; - const deletionDate = new Date(); - deletionDate.setDate(deletionDate.getDate() + 7); - const sendView: SendView = { - id: sendId, - deletionDate, - accessId: "abc", - urlB64Key: "123", - } as SendView; + let sendView: SendView; + let sendViewsSubject: BehaviorSubject; beforeEach(async () => { - i18nService = mock(); platformUtilsService = mock(); sendService = mock(); toastService = mock(); @@ -54,6 +46,17 @@ describe("SendCreatedComponent", () => { activatedRoute = mock(); environmentService = mock(); router = mock(); + + sendView = { + id: sendId, + deletionDate: new Date(), + accessId: "abc", + urlB64Key: "123", + } as SendView; + + sendViewsSubject = new BehaviorSubject([sendView]); + sendService.sendViews$ = sendViewsSubject.asObservable(); + Object.defineProperty(environmentService, "environment$", { configurable: true, get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), @@ -65,8 +68,6 @@ describe("SendCreatedComponent", () => { }, } as any; - sendService.sendViews$ = of([sendView]); - await TestBed.configureTestingModule({ imports: [ CommonModule, @@ -82,7 +83,25 @@ describe("SendCreatedComponent", () => { SendCreatedComponent, ], providers: [ - { provide: I18nService, useValue: i18nService }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + back: "back", + loading: "loading", + copyLink: "copyLink", + close: "close", + createdSend: "createdSend", + createdSendSuccessfully: "createdSendSuccessfully", + popOutNewWindow: "popOutNewWindow", + sendExpiresInHours: (hours) => `sendExpiresInHours ${hours}`, + sendExpiresInHoursSingle: "sendExpiresInHoursSingle", + sendExpiresInDays: (days) => `sendExpiresInDays ${days}`, + sendExpiresInDaysSingle: "sendExpiresInDaysSingle", + sendLinkCopied: "sendLinkCopied", + }); + }, + }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: SendService, useValue: sendService }, { provide: ToastService, useValue: toastService }, @@ -94,40 +113,73 @@ describe("SendCreatedComponent", () => { { provide: Router, useValue: router }, ], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(SendCreatedComponent); component = fixture.componentInstance; + fixture.detectChanges(); }); it("should create", () => { - fixture.detectChanges(); expect(component).toBeTruthy(); }); - it("should initialize send and daysAvailable", () => { - fixture.detectChanges(); + it("should initialize send, daysAvailable, and hoursAvailable", () => { expect(component["send"]).toBe(sendView); - expect(component["daysAvailable"]).toBe(7); + expect(component["daysAvailable"]).toBe(0); + expect(component["hoursAvailable"]).toBe(0); }); it("should navigate back to send list on close", async () => { - fixture.detectChanges(); await component.close(); expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]); }); - describe("getDaysAvailable", () => { - it("returns the correct number of days", () => { + describe("getHoursAvailable", () => { + it("returns the correct number of hours", () => { + sendView.deletionDate.setDate(sendView.deletionDate.getDate() + 7); + sendViewsSubject.next([sendView]); fixture.detectChanges(); - expect(component.getDaysAvailable(sendView)).toBe(7); + + expect(component.getHoursAvailable(sendView)).toBeCloseTo(168, 0); + }); + }); + + describe("formatExpirationDate", () => { + it("returns days plural if expiry is more than 24 hours", () => { + sendView.deletionDate.setDate(sendView.deletionDate.getDate() + 7); + sendViewsSubject.next([sendView]); + fixture.detectChanges(); + + expect(component.formatExpirationDate()).toBe("sendExpiresInDays 7"); + }); + + it("returns days singular if expiry is 24 hours", () => { + sendView.deletionDate.setDate(sendView.deletionDate.getDate() + 1); + sendViewsSubject.next([sendView]); + fixture.detectChanges(); + + expect(component.formatExpirationDate()).toBe("sendExpiresInDaysSingle"); + }); + + it("returns hours plural if expiry is more than 1 hour but less than 24", () => { + sendView.deletionDate.setHours(sendView.deletionDate.getHours() + 2); + sendViewsSubject.next([sendView]); + fixture.detectChanges(); + + expect(component.formatExpirationDate()).toBe("sendExpiresInHours 2"); + }); + + it("returns hours singular if expiry is in 1 hour", () => { + sendView.deletionDate.setHours(sendView.deletionDate.getHours() + 1); + sendViewsSubject.next([sendView]); + fixture.detectChanges(); + + expect(component.formatExpirationDate()).toBe("sendExpiresInHoursSingle"); }); }); describe("copyLink", () => { it("should copy link and show toast", async () => { - fixture.detectChanges(); const link = "https://example.com/#/send/abc/123"; await component.copyLink(); @@ -136,7 +188,7 @@ describe("SendCreatedComponent", () => { expect(toastService.showToast).toHaveBeenCalledWith({ variant: "success", title: null, - message: i18nService.t("sendLinkCopied"), + message: "sendLinkCopied", }); }); }); diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 4ed4da2f81d..ae66d14d3f0 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -39,6 +39,7 @@ export class SendCreatedComponent { protected sendCreatedIcon = SendCreatedIcon; protected send: SendView; protected daysAvailable = 0; + protected hoursAvailable = 0; constructor( private i18nService: I18nService, @@ -54,14 +55,26 @@ export class SendCreatedComponent { this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => { this.send = sendViews.find((s) => s.id === sendId); if (this.send) { - this.daysAvailable = this.getDaysAvailable(this.send); + this.hoursAvailable = this.getHoursAvailable(this.send); + this.daysAvailable = Math.ceil(this.hoursAvailable / 24); } }); } - getDaysAvailable(send: SendView): number { + formatExpirationDate(): string { + if (this.hoursAvailable < 24) { + return this.hoursAvailable === 1 + ? this.i18nService.t("sendExpiresInHoursSingle") + : this.i18nService.t("sendExpiresInHours", this.hoursAvailable); + } + return this.daysAvailable === 1 + ? this.i18nService.t("sendExpiresInDaysSingle") + : this.i18nService.t("sendExpiresInDays", this.daysAvailable); + } + + getHoursAvailable(send: SendView): number { const now = new Date().getTime(); - return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60 * 24))); + return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60))); } async close() { diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index ed6f2420b66..24b6a8085c5 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -304,9 +304,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.24" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" +checksum = "2e80e3b6a3ab07840e1cae9b0666a63970dc28e8ed5ffbcdacbfc760c281bfc1" dependencies = [ "shlex", ] @@ -725,9 +725,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -735,15 +735,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -752,9 +752,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -771,9 +771,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -782,21 +782,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -936,9 +936,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" [[package]] name = "heck" @@ -975,9 +975,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown", @@ -1378,21 +1378,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.20.1" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" -dependencies = [ - "portable-atomic", -] +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "option-ext" @@ -1503,12 +1500,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "portable-atomic" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" - [[package]] name = "powerfmt" version = "0.2.0" @@ -1535,9 +1526,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] diff --git a/apps/desktop/src/app/tools/generator/credential-generator.component.html b/apps/desktop/src/app/tools/generator/credential-generator.component.html index 4ae308e53f7..423c7119eab 100644 --- a/apps/desktop/src/app/tools/generator/credential-generator.component.html +++ b/apps/desktop/src/app/tools/generator/credential-generator.component.html @@ -1,8 +1,7 @@ {{ "generator" | i18n }} - - + - - - - - - + bitIconButton="bwi-generate" + bitSuffix + [appA11yTitle]="'generatePassword' | i18n" + (click)="generatePassword()" + > + + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts index ce605a6f5aa..46d0c550947 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts @@ -1,16 +1,9 @@ -import { - Component, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; -import zxcvbn from "zxcvbn"; -import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component"; +import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -22,27 +15,60 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { OrganizationUserResetPasswordService } from "../services/organization-user-reset-password/organization-user-reset-password.service"; +/** + * Encapsulates a few key data inputs needed to initiate an account recovery + * process for the organization user in question. + */ +export type ResetPasswordDialogData = { + /** + * The organization user's full name + */ + name: string; + + /** + * The organization user's email address + */ + email: string; + + /** + * The `organizationUserId` for the user + */ + id: string; + + /** + * The organization's `organizationId` + */ + organizationId: string; +}; + +export enum ResetPasswordDialogResult { + Ok = "ok", +} + @Component({ selector: "app-reset-password", templateUrl: "reset-password.component.html", }) +/** + * Used in a dialog for initiating the account recovery process against a + * given organization user. An admin will access this form when they want to + * reset a user's password and log them out of sessions. + */ export class ResetPasswordComponent implements OnInit, OnDestroy { - @Input() name: string; - @Input() email: string; - @Input() id: string; - @Input() organizationId: string; - @Output() passwordReset = new EventEmitter(); - @ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent; + formGroup = this.formBuilder.group({ + newPassword: ["", Validators.required], + }); + + @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component; enforcedPolicyOptions: MasterPasswordPolicyOptions; - newPassword: string = null; showPassword = false; - passwordStrengthResult: zxcvbn.ZXCVBNResult; - formPromise: Promise; + passwordStrengthScore: number; private destroy$ = new Subject(); constructor( + @Inject(DIALOG_DATA) protected data: ResetPasswordDialogData, private resetPasswordService: OrganizationUserResetPasswordService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, @@ -51,6 +77,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { private logService: LogService, private dialogService: DialogService, private toastService: ToastService, + private formBuilder: FormBuilder, + private dialogRef: DialogRef, ) {} async ngOnInit() { @@ -69,13 +97,15 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { } get loggedOutWarningName() { - return this.name != null ? this.name : this.i18nService.t("thisUser"); + return this.data.name != null ? this.data.name : this.i18nService.t("thisUser"); } async generatePassword() { const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; - this.newPassword = await this.passwordGenerationService.generatePassword(options); - this.passwordStrengthComponent.updatePasswordStrength(this.newPassword); + this.formGroup.patchValue({ + newPassword: await this.passwordGenerationService.generatePassword(options), + }); + this.passwordStrengthComponent.updatePasswordStrength(this.formGroup.value.newPassword); } togglePassword() { @@ -83,7 +113,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { document.getElementById("newPassword").focus(); } - copy(value: string) { + copy() { + const value = this.formGroup.value.newPassword; if (value == null) { return; } @@ -96,9 +127,9 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { }); } - async submit() { + submit = async () => { // Validation - if (this.newPassword == null || this.newPassword === "") { + if (this.formGroup.value.newPassword == null || this.formGroup.value.newPassword === "") { this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), @@ -107,7 +138,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { return false; } - if (this.newPassword.length < Utils.minimumPasswordLength) { + if (this.formGroup.value.newPassword.length < Utils.minimumPasswordLength) { this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), @@ -119,8 +150,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { if ( this.enforcedPolicyOptions != null && !this.policyService.evaluateMasterPassword( - this.passwordStrengthResult.score, - this.newPassword, + this.passwordStrengthScore, + this.formGroup.value.newPassword, this.enforcedPolicyOptions, ) ) { @@ -132,7 +163,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { return; } - if (this.passwordStrengthResult.score < 3) { + if (this.passwordStrengthScore < 3) { const result = await this.dialogService.openSimpleDialog({ title: { key: "weakMasterPassword" }, content: { key: "weakMasterPasswordDesc" }, @@ -145,26 +176,29 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { } try { - this.formPromise = this.resetPasswordService.resetMasterPassword( - this.newPassword, - this.email, - this.id, - this.organizationId, + await this.resetPasswordService.resetMasterPassword( + this.formGroup.value.newPassword, + this.data.email, + this.data.id, + this.data.organizationId, ); - await this.formPromise; this.toastService.showToast({ variant: "success", title: null, message: this.i18nService.t("resetPasswordSuccess"), }); - this.passwordReset.emit(); } catch (e) { this.logService.error(e); } - this.formPromise = null; + + this.dialogRef.close(ResetPasswordDialogResult.Ok); + }; + + getStrengthScore(result: number) { + this.passwordStrengthScore = result; } - getStrengthResult(result: zxcvbn.ZXCVBNResult) { - this.passwordStrengthResult = result; - } + static open = (dialogService: DialogService, input: DialogConfig) => { + return dialogService.open(ResetPasswordComponent, input); + }; } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 698c260632d..3cc73c84a97 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -70,7 +70,10 @@ import { MemberDialogTab, openUserAddEditDialog, } from "./components/member-dialog"; -import { ResetPasswordComponent } from "./components/reset-password.component"; +import { + ResetPasswordComponent, + ResetPasswordDialogResult, +} from "./components/reset-password.component"; class MembersTableDataSource extends PeopleTableDataSource { protected statusType = OrganizationUserStatusType; @@ -663,24 +666,19 @@ export class MembersComponent extends BaseMembersComponent } async resetPassword(user: OrganizationUserView) { - const [modal] = await this.modalService.openViewRef( - ResetPasswordComponent, - this.resetPasswordModalRef, - (comp) => { - comp.name = this.userNamePipe.transform(user); - comp.email = user != null ? user.email : null; - comp.organizationId = this.organization.id; - comp.id = user != null ? user.id : null; - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.passwordReset.subscribe(() => { - modal.close(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); - }); + const dialogRef = ResetPasswordComponent.open(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + email: user != null ? user.email : null, + organizationId: this.organization.id, + id: user != null ? user.id : null, }, - ); + }); + + const result = await lastValueFrom(dialogRef.closed); + if (result === ResetPasswordDialogResult.Ok) { + await this.load(); + } } protected async removeUserConfirmationDialog(user: OrganizationUserView) { diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 6e8870a675f..d849b1f1f3c 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -1,6 +1,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; +import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { LooseComponentsModule } from "../../../shared"; @@ -24,6 +25,7 @@ import { MembersComponent } from "./members.component"; UserDialogModule, PasswordCalloutComponent, ScrollingModule, + PasswordStrengthV2Component, ], declarations: [ BulkConfirmComponent, diff --git a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts index 538cc45ac63..a36b267e2fe 100644 --- a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts @@ -62,6 +62,13 @@ const routes: Routes = [ (m) => m.OrganizationReportingModule, ), }, + { + path: "access-intelligence", + loadChildren: () => + import("../../tools/access-intelligence/access-intelligence.module").then( + (m) => m.AccessIntelligenceModule, + ), + }, { path: "billing", loadChildren: () => diff --git a/apps/web/src/app/auth/sso.component.ts b/apps/web/src/app/auth/sso.component.ts index e498384c278..019ab5e5ac4 100644 --- a/apps/web/src/app/auth/sso.component.ts +++ b/apps/web/src/app/auth/sso.component.ts @@ -12,11 +12,14 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response"; +import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { HttpStatusCode } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -107,13 +110,24 @@ export class SsoComponent extends BaseSsoComponent implements OnInit { // show loading spinner this.loggingIn = true; try { - const response: OrganizationDomainSsoDetailsResponse = - await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email); + if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) { + const response: ListResponse = + await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email); - if (response?.ssoAvailable && response?.verifiedDate) { - this.identifierFormControl.setValue(response.organizationIdentifier); - await this.submit(); - return; + if (response.data.length > 0) { + this.identifierFormControl.setValue(response.data[0].organizationIdentifier); + await this.submit(); + return; + } + } else { + const response: OrganizationDomainSsoDetailsResponse = + await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email); + + if (response?.ssoAvailable && response?.verifiedDate) { + this.identifierFormControl.setValue(response.organizationIdentifier); + await this.submit(); + return; + } } } catch (error) { this.handleGetClaimedDomainByEmailError(error); diff --git a/apps/web/src/app/billing/individual/billing-history-view.component.html b/apps/web/src/app/billing/individual/billing-history-view.component.html index 7dbd8d1792a..0b090bbabdd 100644 --- a/apps/web/src/app/billing/individual/billing-history-view.component.html +++ b/apps/web/src/app/billing/individual/billing-history-view.component.html @@ -11,8 +11,12 @@ > {{ "loading" | i18n }} - - + + + + + + + + + +
{{ "options" | i18n }}
+
+
+ +
+ + {{ "type" | i18n }} + + {{ + credentialTypeHint$ | async + }} + +
+ + + +
+
+
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts new file mode 100644 index 00000000000..359c7505c54 --- /dev/null +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -0,0 +1,293 @@ +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + concat, + distinctUntilChanged, + filter, + map, + of, + ReplaySubject, + Subject, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { Option } from "@bitwarden/components/src/select/option"; +import { + CredentialAlgorithm, + CredentialCategory, + CredentialGeneratorInfo, + CredentialGeneratorService, + GeneratedCredential, + Generators, + isEmailAlgorithm, + isPasswordAlgorithm, + isUsernameAlgorithm, + PasswordAlgorithm, +} from "@bitwarden/generator-core"; + +/** root category that drills into username and email categories */ +const IDENTIFIER = "identifier"; +/** options available for the top-level navigation */ +type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; + +@Component({ + selector: "tools-credential-generator", + templateUrl: "credential-generator.component.html", +}) +export class CredentialGeneratorComponent implements OnInit, OnDestroy { + constructor( + private generatorService: CredentialGeneratorService, + private i18nService: I18nService, + private accountService: AccountService, + private zone: NgZone, + private formBuilder: FormBuilder, + ) {} + + /** Binds the component to a specific user's settings. When this input is not provided, + * the form binds to the active user + */ + @Input() + userId: UserId | null; + + /** Emits credentials created from a generation request. */ + @Output() + readonly onGenerated = new EventEmitter(); + + protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ + nav: null, + }); + + protected onRootChanged(nav: RootNavValue) { + // prevent subscription cycle + if (this.root$.value.nav !== nav) { + this.zone.run(() => { + this.root$.next({ nav }); + }); + } + } + + protected username = this.formBuilder.group({ + nav: [null as CredentialAlgorithm], + }); + + async ngOnInit() { + if (this.userId) { + this.userId$.next(this.userId); + } else { + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); + } + + this.generatorService + .algorithms$(["email", "username"], { userId$: this.userId$ }) + .pipe( + map((algorithms) => this.toOptions(algorithms)), + takeUntil(this.destroyed), + ) + .subscribe(this.usernameOptions$); + + this.generatorService + .algorithms$("password", { userId$: this.userId$ }) + .pipe( + map((algorithms) => { + const options = this.toOptions(algorithms) as Option[]; + options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); + return options; + }), + takeUntil(this.destroyed), + ) + .subscribe(this.rootOptions$); + + this.algorithm$ + .pipe( + map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + takeUntil(this.destroyed), + ) + .subscribe((hint) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.credentialTypeHint$.next(hint); + }); + }); + + this.algorithm$ + .pipe( + map((a) => a.category), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe((category) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.category$.next(category); + }); + }); + + // wire up the generator + this.algorithm$ + .pipe( + switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + takeUntil(this.destroyed), + ) + .subscribe((generated) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.onGenerated.next(generated); + this.value$.next(generated.credential); + }); + }); + + // assume the last-visible generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.root$ + .pipe( + filter(({ nav }) => !!nav), + switchMap((root) => { + if (root.nav === IDENTIFIER) { + return concat(of(this.username.value), this.username.valueChanges); + } else { + return of(root as { nav: PasswordAlgorithm }); + } + }), + filter(({ nav }) => !!nav), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) + .subscribe(([{ nav: algorithm }, preference]) => { + function setPreference(category: CredentialCategory) { + const p = preference[category]; + p.algorithm = algorithm; + p.updated = new Date(); + } + + // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` + if (isEmailAlgorithm(algorithm)) { + setPreference("email"); + } else if (isUsernameAlgorithm(algorithm)) { + setPreference("username"); + } else if (isPasswordAlgorithm(algorithm)) { + setPreference("password"); + } else { + return; + } + + preferences.next(preference); + }); + + // populate the form with the user's preferences to kick off interactivity + preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { + // the last preference set by the user "wins" + const userNav = email.updated > username.updated ? email : username; + const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm; + const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm; + + // update navigation; break subscription loop + this.onRootChanged(rootNav); + this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false }); + + // load algorithm metadata + const algorithm = this.generatorService.algorithm(credentialType); + + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$ + .pipe( + distinctUntilChanged((prev, next) => prev.id === next.id), + filter((a) => !a.onlyOnRequest), + takeUntil(this.destroyed), + ) + .subscribe(() => this.generate$.next()); + } + + private typeToGenerator$(type: CredentialAlgorithm) { + const dependencies = { + on$: this.generate$, + userId$: this.userId$, + }; + + switch (type) { + case "catchall": + return this.generatorService.generate$(Generators.catchall, dependencies); + + case "subaddress": + return this.generatorService.generate$(Generators.subaddress, dependencies); + + case "username": + return this.generatorService.generate$(Generators.username, dependencies); + + case "password": + return this.generatorService.generate$(Generators.password, dependencies); + + case "passphrase": + return this.generatorService.generate$(Generators.passphrase, dependencies); + + default: + throw new Error(`Invalid generator type: "${type}"`); + } + } + + /** Lists the credential types of the username algorithm box. */ + protected usernameOptions$ = new BehaviorSubject[]>([]); + + /** Lists the top-level credential types supported by the component. */ + protected rootOptions$ = new BehaviorSubject[]>([]); + + /** tracks the currently selected credential type */ + protected algorithm$ = new ReplaySubject(1); + + /** Emits hint key for the currently selected credential type */ + protected credentialTypeHint$ = new ReplaySubject(1); + + /** tracks the currently selected credential category */ + protected category$ = new ReplaySubject(1); + + /** Emits the last generated value. */ + protected readonly value$ = new BehaviorSubject(""); + + /** Emits when the userId changes */ + protected readonly userId$ = new BehaviorSubject(null); + + /** Emits when a new credential is requested */ + protected readonly generate$ = new Subject(); + + private toOptions(algorithms: CredentialGeneratorInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: algorithm.id, + label: this.i18nService.t(algorithm.nameKey), + })); + + return options; + } + + private readonly destroyed = new Subject(); + ngOnDestroy() { + this.destroyed.complete(); + + // finalize subjects + this.generate$.complete(); + this.value$.complete(); + + // finalize component bindings + this.onGenerated.complete(); + } +} diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/generator.module.ts similarity index 62% rename from libs/tools/generator/components/src/dependencies.ts rename to libs/tools/generator/components/src/generator.module.ts index 6f2c13e0579..c7dfc60bab2 100644 --- a/libs/tools/generator/components/src/dependencies.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -10,8 +10,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, - CheckboxModule, ColorPasswordModule, + CheckboxModule, FormFieldModule, IconButtonModule, InputModule, @@ -27,16 +27,24 @@ import { Randomizer, } from "@bitwarden/generator-core"; +import { CatchallSettingsComponent } from "./catchall-settings.component"; +import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordGeneratorComponent } from "./password-generator.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; +import { SubaddressSettingsComponent } from "./subaddress-settings.component"; +import { UsernameGeneratorComponent } from "./username-generator.component"; +import { UsernameSettingsComponent } from "./username-settings.component"; + const RANDOMIZER = new SafeInjectionToken("Randomizer"); /** Shared module containing generator component dependencies */ @NgModule({ - imports: [CardComponent, SectionComponent, SectionHeaderComponent], - exports: [ + imports: [ CardComponent, + ColorPasswordModule, CheckboxModule, CommonModule, - ColorPasswordModule, FormFieldModule, IconButtonModule, InputModule, @@ -60,8 +68,18 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); deps: [RANDOMIZER, StateProvider, PolicyService], }), ], - declarations: [], + declarations: [ + CatchallSettingsComponent, + CredentialGeneratorComponent, + SubaddressSettingsComponent, + UsernameSettingsComponent, + PasswordGeneratorComponent, + PasswordSettingsComponent, + PassphraseSettingsComponent, + UsernameGeneratorComponent, + ], + exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) -export class DependenciesModule { +export class GeneratorModule { constructor() {} } diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 9367a32546c..213461174f0 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1,9 +1,3 @@ -export { CatchallSettingsComponent } from "./catchall-settings.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; -export { PassphraseSettingsComponent } from "./passphrase-settings.component"; -export { PasswordSettingsComponent } from "./password-settings.component"; -export { PasswordGeneratorComponent } from "./password-generator.component"; -export { SubaddressSettingsComponent } from "./subaddress-settings.component"; -export { UsernameGeneratorComponent } from "./username-generator.component"; -export { UsernameSettingsComponent } from "./username-settings.component"; +export { GeneratorModule } from "./generator.module"; diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index bfb3425bf63..25e028210cc 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -10,7 +10,6 @@ import { PassphraseGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch, toValidators } from "./util"; const Controls = Object.freeze({ @@ -22,10 +21,8 @@ const Controls = Object.freeze({ /** Options group for passphrases */ @Component({ - standalone: true, selector: "tools-passphrase-settings", templateUrl: "passphrase-settings.component.html", - imports: [DependenciesModule], }) export class PassphraseSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 62bcdfa15da..7ec3a565dd3 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -5,11 +5,8 @@ (selectedChange)="onCredentialTypeChanged($event)" attr.aria-label="{{ 'type' | i18n }}" > - - {{ "password" | i18n }} - - - {{ "passphrase" | i18n }} + + {{ option.label }} @@ -24,6 +21,7 @@ type="button" bitIconButton="bwi-clone" buttonType="main" + showToast [appCopyClick]="value$ | async" > {{ "copyPassword" | i18n }} @@ -32,13 +30,13 @@ diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index b6d8fbf60da..bf33c7cfca9 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,29 +1,39 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; -import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + ReplaySubject, + Subject, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { Option } from "@bitwarden/components/src/select/option"; import { CredentialGeneratorService, Generators, PasswordAlgorithm, GeneratedCredential, + CredentialGeneratorInfo, + CredentialAlgorithm, + isPasswordAlgorithm, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; -import { PassphraseSettingsComponent } from "./passphrase-settings.component"; -import { PasswordSettingsComponent } from "./password-settings.component"; - /** Options group for passwords */ @Component({ - standalone: true, selector: "tools-password-generator", templateUrl: "password-generator.component.html", - imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent], }) export class PasswordGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, ) {} @@ -36,7 +46,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { userId: UserId | null; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject("password"); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -51,9 +61,11 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { * @param type the new credential type */ protected onCredentialTypeChanged(type: PasswordAlgorithm) { + // break subscription cycle if (this.credentialType$.value !== type) { - this.credentialType$.next(type); - this.generate$.next(); + this.zone.run(() => { + this.credentialType$.next(type); + }); } } @@ -74,9 +86,18 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { .subscribe(this.userId$); } - this.credentialType$ + this.generatorService + .algorithms$("password", { userId$: this.userId$ }) .pipe( - switchMap((type) => this.typeToGenerator$(type)), + map((algorithms) => this.toOptions(algorithms)), + takeUntil(this.destroyed), + ) + .subscribe(this.passwordOptions$); + + // wire up the generator + this.algorithm$ + .pipe( + switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -87,9 +108,52 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { this.value$.next(generated.credential); }); }); + + // assume the last-visible generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.credentialType$ + .pipe( + filter((type) => !!type), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) + .subscribe(([algorithm, preference]) => { + if (isPasswordAlgorithm(algorithm)) { + preference.password.algorithm = algorithm; + preference.password.updated = new Date(); + } else { + return; + } + + preferences.next(preference); + }); + + // populate the form with the user's preferences to kick off interactivity + preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => { + // update navigation + this.onCredentialTypeChanged(password.algorithm); + + // load algorithm metadata + const algorithm = this.generatorService.algorithm(password.algorithm); + + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$ + .pipe( + distinctUntilChanged((prev, next) => prev.id === next.id), + filter((a) => !a.onlyOnRequest), + takeUntil(this.destroyed), + ) + .subscribe(() => this.generate$.next()); } - private typeToGenerator$(type: PasswordAlgorithm) { + private typeToGenerator$(type: CredentialAlgorithm) { const dependencies = { on$: this.generate$, userId$: this.userId$, @@ -106,6 +170,21 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { } } + /** Lists the credential types supported by the component. */ + protected passwordOptions$ = new BehaviorSubject[]>([]); + + /** tracks the currently selected credential type */ + protected algorithm$ = new ReplaySubject(1); + + private toOptions(algorithms: CredentialGeneratorInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: algorithm.id, + label: this.i18nService.t(algorithm.nameKey), + })); + + return options; + } + private readonly destroyed = new Subject(); ngOnDestroy(): void { // tear down subscriptions diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index d9fd6cd99c9..9466c81a0f4 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -10,7 +10,6 @@ import { PasswordGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch, toValidators } from "./util"; const Controls = Object.freeze({ @@ -26,10 +25,8 @@ const Controls = Object.freeze({ /** Options group for passwords */ @Component({ - standalone: true, selector: "tools-password-settings", templateUrl: "password-settings.component.html", - imports: [DependenciesModule], }) export class PasswordSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index ed55cb51ba0..30db8dc657d 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -10,15 +10,12 @@ import { SubaddressGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch } from "./util"; /** Options group for plus-addressed emails */ @Component({ - standalone: true, selector: "tools-subaddress-settings", templateUrl: "subaddress-settings.component.html", - imports: [DependenciesModule], }) export class SubaddressSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 413de931452..a44637d78e5 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -10,6 +10,7 @@ type="button" bitIconButton="bwi-clone" buttonType="main" + showToast [appCopyClick]="value$ | async" > {{ "copyPassword" | i18n }} diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index e5327cc66eb..767c73c398a 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -26,23 +26,10 @@ import { isUsernameAlgorithm, } from "@bitwarden/generator-core"; -import { CatchallSettingsComponent } from "./catchall-settings.component"; -import { DependenciesModule } from "./dependencies"; -import { SubaddressSettingsComponent } from "./subaddress-settings.component"; -import { UsernameSettingsComponent } from "./username-settings.component"; -import { completeOnAccountSwitch } from "./util"; - /** Component that generates usernames and emails */ @Component({ - standalone: true, selector: "tools-username-generator", templateUrl: "username-generator.component.html", - imports: [ - DependenciesModule, - CatchallSettingsComponent, - SubaddressSettingsComponent, - UsernameSettingsComponent, - ], }) export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Instantiates the username generator @@ -72,14 +59,20 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Tracks the selected generation algorithm */ protected credential = this.formBuilder.group({ - type: ["username" as CredentialAlgorithm], + type: [null as CredentialAlgorithm], }); async ngOnInit() { if (this.userId) { this.userId$.next(this.userId); } else { - this.singleUserId$().pipe(takeUntil(this.destroyed)).subscribe(this.userId$); + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); } this.generatorService @@ -121,7 +114,11 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); this.credential.valueChanges - .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) + .pipe( + filter(({ type }) => !!type), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) .subscribe(([{ type }, preference]) => { if (isEmailAlgorithm(type)) { preference.email.algorithm = type; @@ -202,19 +199,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private singleUserId$() { - // FIXME: this branch should probably scan for the user and make sure - // the account is unlocked - if (this.userId) { - return new BehaviorSubject(this.userId as UserId).asObservable(); - } - - return this.accountService.activeAccount$.pipe( - completeOnAccountSwitch(), - takeUntil(this.destroyed), - ); - } - private toOptions(algorithms: CredentialGeneratorInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index 978bd05ca79..8237b8674cd 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -10,15 +10,12 @@ import { Generators, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch } from "./util"; /** Options group for usernames */ @Component({ - standalone: true, selector: "tools-username-settings", templateUrl: "username-settings.component.html", - imports: [DependenciesModule], }) export class UsernameSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index 29235401de2..9699f832ed0 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -12,8 +12,8 @@ > - {{ "password" | i18n }} - {{ "newPassword" | i18n }} + {{ "password" | i18n }} + {{ "newPassword" | i18n }} +