diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7b30e5aa334..e52f78583d4 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2498,7 +2498,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": { 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/background/main.background.ts b/apps/browser/src/background/main.background.ts index 85e1ee19b07..318b856b324 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1103,6 +1103,7 @@ export default class MainBackground { this.vaultSettingsService, this.scriptInjectorService, this.configService, + this.authService, ); const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService); @@ -1118,7 +1119,6 @@ export default class MainBackground { this.messagingService, this.logService, this.configService, - this.fido2Background, messageListener, this.accountService, lockService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 3e0933942b5..2bc2eadf261 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -21,7 +21,6 @@ import { openTwoFactorAuthPopout, } from "../auth/popup/utils/auth-popout-window"; import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background"; -import { Fido2Background } from "../autofill/fido2/background/abstractions/fido2.background"; import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; @@ -46,7 +45,6 @@ export default class RuntimeBackground { private messagingService: MessagingService, private logService: LogService, private configService: ConfigService, - private fido2Background: Fido2Background, private messageListener: MessageListener, private accountService: AccountService, private readonly lockService: LockService, @@ -365,7 +363,6 @@ export default class RuntimeBackground { private async checkOnInstalled() { setTimeout(async () => { - void this.fido2Background.injectFido2ContentScriptsInAllTabs(); void this.autofillService.loadAutofillScriptsOnInstall(); if (this.onInstalledReason != null) { diff --git a/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts index b0dc60ed126..cce41a61ee4 100644 --- a/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts +++ b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts @@ -43,23 +43,17 @@ function buildRegisterContentScriptsPolyfill() { function NestedProxy(target: T): T { return new Proxy(target, { get(target, prop) { - const propertyValue = target[prop as keyof T]; - - if (!propertyValue) { + if (!target[prop as keyof T]) { return; } - if (typeof propertyValue === "object") { - return NestedProxy(propertyValue); - } - - if (typeof propertyValue !== "function") { - return propertyValue; + if (typeof target[prop as keyof T] !== "function") { + return NestedProxy(target[prop as keyof T] as object); } return (...arguments_: any[]) => new Promise((resolve, reject) => { - propertyValue(...arguments_, (result: any) => { + (target[prop as keyof T] as CallableFunction)(...arguments_, (result: any) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); } else { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index c97d3da1396..7c65cbeb17d 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -16,7 +16,9 @@ >

{{ "createdSendSuccessfully" | i18n }}

-

{{ "sendAvailability" | i18n: daysAvailable }}

+

+ {{ formatExpirationDate() }} +

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/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 336285b2a45..23e9c6df178 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -39,6 +39,11 @@ *ngIf="organization.canAccessReports" > + ; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; + isAccessIntelligenceFeatureEnabled = false; private _destroy = new Subject(); @@ -70,6 +71,10 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { async ngOnInit() { document.body.classList.remove("layout_frontend"); + this.isAccessIntelligenceFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.AccessIntelligence, + ); + this.organization$ = this.route.params .pipe(takeUntil(this._destroy)) .pipe(map((p) => p.organizationId)) diff --git a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.html b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.html index 1d39bcd0e9a..b5f50841e13 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.html @@ -1,100 +1,67 @@ - + 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 25c9824bce9..5277da86488 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/tools/access-intelligence/access-intelligence-routing.module.ts b/apps/web/src/app/tools/access-intelligence/access-intelligence-routing.module.ts new file mode 100644 index 00000000000..b35b1fa64a3 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +import { unauthGuardFn } from "@bitwarden/angular/auth/guards"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + +import { AccessIntelligenceComponent } from "./access-intelligence.component"; + +const routes: Routes = [ + { + path: "", + component: AccessIntelligenceComponent, + canActivate: [canAccessFeature(FeatureFlag.AccessIntelligence), unauthGuardFn()], + data: { + titleId: "accessIntelligence", + }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AccessIntelligenceRoutingModule {} diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html new file mode 100644 index 00000000000..665f8f6b0c5 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html @@ -0,0 +1,23 @@ + + + +

{{ "allApplications" | i18n }}

+ +
+ + + + {{ "priorityApplicationsWithCount" | i18n: priorityApps.length }} + +

{{ "priorityApplications" | i18n }}

+ +
+ + + + {{ "notifiedMembersWithCount" | i18n: priorityApps.length }} + +

{{ "notifiedMembers" | i18n }}

+ +
+
diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts new file mode 100644 index 00000000000..9e5eff6f629 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts @@ -0,0 +1,45 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { first } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { TabsModule } from "@bitwarden/components"; + +import { HeaderModule } from "../../layouts/header/header.module"; + +import { ApplicationTableComponent } from "./application-table.component"; +import { NotifiedMembersTableComponent } from "./notified-members-table.component"; + +export enum AccessIntelligenceTabType { + AllApps = 0, + PriorityApps = 1, + NotifiedMembers = 2, +} + +@Component({ + standalone: true, + templateUrl: "./access-intelligence.component.html", + imports: [ + ApplicationTableComponent, + CommonModule, + JslibModule, + HeaderModule, + NotifiedMembersTableComponent, + TabsModule, + ], +}) +export class AccessIntelligenceComponent { + tabIndex: AccessIntelligenceTabType; + + apps: any[] = []; + priorityApps: any[] = []; + notifiedMembers: any[] = []; + + constructor(route: ActivatedRoute) { + route.queryParams.pipe(takeUntilDestroyed(), first()).subscribe(({ tabIndex }) => { + this.tabIndex = !isNaN(tabIndex) ? tabIndex : AccessIntelligenceTabType.AllApps; + }); + } +} diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts b/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts new file mode 100644 index 00000000000..32b66935b69 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; +import { AccessIntelligenceComponent } from "./access-intelligence.component"; + +@NgModule({ + imports: [AccessIntelligenceComponent, AccessIntelligenceRoutingModule], +}) +export class AccessIntelligenceModule {} diff --git a/apps/web/src/app/tools/access-intelligence/application-table.component.html b/apps/web/src/app/tools/access-intelligence/application-table.component.html new file mode 100644 index 00000000000..4986483cb75 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/application-table.component.html @@ -0,0 +1,11 @@ + + + + {{ "application" | i18n }} + {{ "atRiskPasswords" | i18n }} + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }} + {{ "totalMembers" | i18n }} + + + diff --git a/apps/web/src/app/tools/access-intelligence/application-table.component.ts b/apps/web/src/app/tools/access-intelligence/application-table.component.ts new file mode 100644 index 00000000000..79b8500b8c7 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/application-table.component.ts @@ -0,0 +1,19 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { TableDataSource, TableModule } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "tools-application-table", + templateUrl: "./application-table.component.html", + imports: [CommonModule, JslibModule, TableModule], +}) +export class ApplicationTableComponent { + protected dataSource = new TableDataSource(); + + constructor() { + this.dataSource.data = []; + } +} diff --git a/apps/web/src/app/tools/access-intelligence/notified-members-table.component.html b/apps/web/src/app/tools/access-intelligence/notified-members-table.component.html new file mode 100644 index 00000000000..dc94f28f944 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/notified-members-table.component.html @@ -0,0 +1,11 @@ + + + + {{ "member" | i18n }} + {{ "atRiskPasswords" | i18n }} + {{ "totalPasswords" | i18n }} + {{ "atRiskApplications" | i18n }} + {{ "totalApplications" | i18n }} + + + diff --git a/apps/web/src/app/tools/access-intelligence/notified-members-table.component.ts b/apps/web/src/app/tools/access-intelligence/notified-members-table.component.ts new file mode 100644 index 00000000000..d50436061cb --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/notified-members-table.component.ts @@ -0,0 +1,19 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { TableDataSource, TableModule } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "tools-notified-members-table", + templateUrl: "./notified-members-table.component.html", + imports: [CommonModule, JslibModule, TableModule], +}) +export class NotifiedMembersTableComponent { + dataSource = new TableDataSource(); + + constructor() { + this.dataSource.data = []; + } +} diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 3840d5ec6aa..94bb6011dc7 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -788,8 +788,8 @@ export class VaultComponent implements OnInit, OnDestroy { } /** - * Edit the given cipher - * @param cipherView - The cipher to be edited + * Edit the given cipher or add a new cipher + * @param cipherView - When set, the cipher to be edited * @param cloneCipher - `true` when the cipher should be cloned. * Used in place of the `additionalComponentParameters`, as * the `editCipherIdV2` method has a differing implementation. @@ -797,7 +797,7 @@ export class VaultComponent implements OnInit, OnDestroy { * the `AddEditComponent` to edit methods directly. */ async editCipher( - cipher: CipherView, + cipher: CipherView | null, cloneCipher: boolean, additionalComponentParameters?: (comp: AddEditComponent) => void, ) { @@ -805,7 +805,7 @@ export class VaultComponent implements OnInit, OnDestroy { } async editCipherId( - cipher: CipherView, + cipher: CipherView | null, cloneCipher: boolean, additionalComponentParameters?: (comp: AddEditComponent) => void, ) { @@ -827,7 +827,7 @@ export class VaultComponent implements OnInit, OnDestroy { const defaultComponentParameters = (comp: AddEditComponent) => { comp.organization = this.organization; comp.organizationId = this.organization.id; - comp.cipherId = cipher.id; + comp.cipherId = cipher?.id; comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); this.refresh(); @@ -866,10 +866,10 @@ export class VaultComponent implements OnInit, OnDestroy { * Edit a cipher using the new AddEditCipherDialogV2 component. * Only to be used behind the ExtensionRefresh feature flag. */ - private async editCipherIdV2(cipher: CipherView, cloneCipher: boolean) { + private async editCipherIdV2(cipher: CipherView | null, cloneCipher: boolean) { const cipherFormConfig = await this.cipherFormConfigService.buildConfig( cloneCipher ? "clone" : "edit", - cipher.id as CipherId, + cipher?.id as CipherId | null, ); await this.openVaultItemDialog("form", cipherFormConfig, cipher); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 96afa9dd1aa..6d2196466f5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1,4 +1,64 @@ { + "allApplications": { + "message": "All applications" + }, + "priorityApplications": { + "message": "Priority applications" + }, + "accessIntelligence": { + "message": "Access Intelligence" + }, + "notifiedMembers": { + "message": "Notified members" + }, + "allApplicationsWithCount": { + "message": "All applications ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "priorityApplicationsWithCount": { + "message": "Priority applications ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notifiedMembersWithCount": { + "message": "Notified members ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "application": { + "message": "Application" + }, + "atRiskPasswords": { + "message": "At-risk passwords" + }, + "totalPasswords": { + "message": "Total passwords" + }, + "atRiskMembers": { + "message": "At-risk members" + }, + "totalMembers": { + "message": "Total members" + }, + "atRiskApplications": { + "message": "At-risk applications" + }, + "totalApplications": { + "message": "Total applications" + }, "whatTypeOfItem": { "message": "What type of item is this?" }, diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 1a89d8e6abc..46d7585ee39 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -192,7 +192,7 @@ @import "./toast/toast.tokens.css"; @import "./toast/toastr.css"; -/** +/** * tw-break-words does not work with table cells: * https://github.com/tailwindlabs/tailwindcss/issues/835 */ @@ -200,7 +200,7 @@ td.tw-break-words { overflow-wrap: anywhere; } -/** +/** * tw-list-none hides summary arrow in Firefox & Chrome but not Safari: * https://github.com/tailwindlabs/tailwindcss/issues/924#issuecomment-915509785 */ @@ -209,7 +209,7 @@ summary.tw-list-none::-webkit-details-marker { display: none; } -/** +/** * Arbitrary values can't be used with `text-align`: * https://github.com/tailwindlabs/tailwindcss/issues/802#issuecomment-849013311 */ @@ -218,10 +218,11 @@ summary.tw-list-none::-webkit-details-marker { } /** - * Bootstrap uses z-index: 1050 for modals, dialogs should appear above them. - * Remove once bootstrap is removed from our codebase. - * CL-XYZ + * Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them. + * When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content. + * CL-483 */ +.cdk-drag-preview, .cdk-overlay-container, .cdk-global-overlay-wrapper, .cdk-overlay-connected-position-bounding-box, 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..b8458fa9c12 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,7 @@ > - {{ "password" | i18n }} - {{ "newPassword" | i18n }} + {{ "password" | i18n }}