1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Vicki League
2024-10-09 13:42:23 -04:00
35 changed files with 711 additions and 276 deletions

View File

@@ -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": {

View File

@@ -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<LogService>;
let getFeatureFlagMock$: BehaviorSubject<boolean>;
let configService: MockProxy<ConfigService>;
let notificationBackground: NotificationBackground;
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
@@ -33,7 +35,10 @@ describe("OverlayNotificationsBackground", () => {
beforeEach(async () => {
jest.useFakeTimers();
logService = mock<LogService>();
configService = mock<ConfigService>();
getFeatureFlagMock$ = new BehaviorSubject(true);
configService = mock<ConfigService>({
getFeatureFlag$: jest.fn().mockReturnValue(getFeatureFlagMock$),
});
notificationBackground = mock<NotificationBackground>();
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
@@ -164,8 +169,17 @@ describe("OverlayNotificationsBackground", () => {
});
describe("storing the modified login form data", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
const sender = mock<chrome.runtime.MessageSender>({ 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<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
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<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",

View File

@@ -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<void> = new Subject();
private notificationFallbackTimeout: number | NodeJS.Timeout | null;
private readonly formSubmissionRequestMethods: Set<string> = 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.
*

View File

@@ -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$);

View File

@@ -45,7 +45,6 @@ type Fido2BackgroundExtensionMessageHandlers = {
interface Fido2Background {
init(): void;
injectFido2ContentScriptsInAllTabs(): Promise<void>;
}
export {

View File

@@ -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<BrowserScriptInjectorService>;
let configServiceMock!: MockProxy<ConfigService>;
let enablePasskeysMock$!: BehaviorSubject<boolean>;
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authServiceMock!: MockProxy<AuthService>;
let fido2Background!: Fido2Background;
beforeEach(() => {
@@ -81,6 +85,9 @@ describe("Fido2Background", () => {
vaultSettingsService.enablePasskeys$ = enablePasskeysMock$;
fido2ActiveRequestManager = mock<Fido2ActiveRequestManager>();
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authServiceMock = mock<AuthService>();
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<chrome.tabs.Tab>({ 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<chrome.tabs.Tab>({ id: 456, url: "https://example.com" });
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
const noUrlTab = mock<chrome.tabs.Tab>({ 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<chrome.runtime.Port>;
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<chrome.runtime.Port>;
beforeEach(() => {
jest.spyOn(fido2Background as any, "handleAuthStatusUpdate").mockImplementation();
fido2Background.init();
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
triggerRuntimeOnConnectEvent(portMock);

View File

@@ -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<chrome.runtime.Port>();
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();

View File

@@ -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;

View File

@@ -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

View File

@@ -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:" ||

View File

@@ -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,

View File

@@ -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) {

View File

@@ -43,23 +43,17 @@ function buildRegisterContentScriptsPolyfill() {
function NestedProxy<T extends object>(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<typeof propertyValue>(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 {

View File

@@ -16,7 +16,9 @@
>
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
<h3 class="tw-font-semibold">{{ "createdSendSuccessfully" | i18n }}</h3>
<p class="tw-text-center">{{ "sendAvailability" | i18n: daysAvailable }}</p>
<p class="tw-text-center">
{{ formatExpirationDate() }}
</p>
<button bitButton type="button" buttonType="primary" (click)="copyLink()">
<b>{{ "copyLink" | i18n }}</b>
</button>

View File

@@ -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<SendCreatedComponent>;
let i18nService: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let sendService: MockProxy<SendService>;
let toastService: MockProxy<ToastService>;
@@ -36,17 +35,10 @@ describe("SendCreatedComponent", () => {
let router: MockProxy<Router>;
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<SendView[]>;
beforeEach(async () => {
i18nService = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>();
sendService = mock<SendService>();
toastService = mock<ToastService>();
@@ -54,6 +46,17 @@ describe("SendCreatedComponent", () => {
activatedRoute = mock<ActivatedRoute>();
environmentService = mock<EnvironmentService>();
router = mock<Router>();
sendView = {
id: sendId,
deletionDate: new Date(),
accessId: "abc",
urlB64Key: "123",
} as SendView;
sendViewsSubject = new BehaviorSubject<SendView[]>([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",
});
});
});

View File

@@ -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() {

View File

@@ -39,6 +39,11 @@
*ngIf="organization.canAccessReports"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
*ngIf="isAccessIntelligenceFeatureEnabled"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
<bit-nav-group
icon="bwi-billing"
[text]="'billing' | i18n"

View File

@@ -51,6 +51,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
showPaymentAndHistory$: Observable<boolean>;
hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>;
isAccessIntelligenceFeatureEnabled = false;
private _destroy = new Subject<void>();
@@ -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<string>(map((p) => p.organizationId))

View File

@@ -1,100 +1,67 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="resetPasswordTitle">
<div class="modal-dialog" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="resetPasswordTitle">
{{ "recoverAccount" | i18n }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h1>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'recoverAccount' | i18n" [subtitle]="data.name">
<ng-container bitDialogContent>
<bit-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n: loggedOutWarningName }}
</bit-callout>
<auth-password-callout
[policy]="enforcedPolicyOptions"
message="resetPasswordMasterPasswordPolicyInEffect"
*ngIf="enforcedPolicyOptions"
>
</auth-password-callout>
<bit-form-field>
<bit-label>
{{ "newPassword" | i18n }}
</bit-label>
<input
id="newPassword"
bitInput
[type]="showPassword ? 'text' : 'password'"
name="NewPassword"
formControlName="newPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n: loggedOutWarningName }}
</app-callout>
<auth-password-callout
[policy]="enforcedPolicyOptions"
message="resetPasswordMasterPasswordPolicyInEffect"
*ngIf="enforcedPolicyOptions"
>
</auth-password-callout>
<div class="row">
<div class="col form-group">
<div class="d-flex">
<label for="newPassword">{{ "newPassword" | i18n }}</label>
<div class="ml-auto d-flex">
<a
href="#"
class="d-block mr-2 bwi-icon-above-input"
appStopClick
appA11yTitle="{{ 'generatePassword' | i18n }}"
(click)="generatePassword()"
>
<i class="bwi bwi-lg bwi-fw bwi-refresh" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="input-group mb-1">
<input
id="newPassword"
class="form-control text-monospace"
appAutofocus
type="{{ showPassword ? 'text' : 'password' }}"
name="NewPassword"
[(ngModel)]="newPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<div class="input-group-append">
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy(newPassword)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<app-password-strength
[password]="newPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>
bitIconButton="bwi-generate"
bitSuffix
[appA11yTitle]="'generatePassword' | i18n"
(click)="generatePassword()"
></button>
<button
type="button"
bitSuffix
[bitIconButton]="showPassword ? 'bwi-eye-slash' : 'bwi-eye'"
buttonType="secondary"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
></button>
<button
type="button"
bitSuffix
bitIconButton="bwi-clone"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
></button>
</bit-form-field>
<tools-password-strength
[password]="formGroup.value.newPassword"
[email]="data.email"
[showText]="true"
(passwordStrengthScore)="getStrengthScore($event)"
>
</tools-password-strength>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -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<any>;
passwordStrengthScore: number;
private destroy$ = new Subject<void>();
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<ResetPasswordDialogResult>,
) {}
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<ResetPasswordDialogData>) => {
return dialogService.open<ResetPasswordDialogResult>(ResetPasswordComponent, input);
};
}

View File

@@ -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<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
@@ -663,24 +666,19 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
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) {

View File

@@ -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,

View File

@@ -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: () =>

View File

@@ -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 {}

View File

@@ -0,0 +1,23 @@
<app-header></app-header>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: apps.length }}">
<h2 bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<tools-application-table></tools-application-table>
</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{ "priorityApplicationsWithCount" | i18n: priorityApps.length }}
</ng-template>
<h2 bitTypography>{{ "priorityApplications" | i18n }}</h2>
<tools-application-table></tools-application-table>
</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-envelope"></i>
{{ "notifiedMembersWithCount" | i18n: priorityApps.length }}
</ng-template>
<h2 bitTypography="h2">{{ "notifiedMembers" | i18n }}</h2>
<tools-notified-members-table></tools-notified-members-table>
</bit-tab>
</bit-tab-group>

View File

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

View File

@@ -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 {}

View File

@@ -0,0 +1,11 @@
<!-- <bit-table [dataSource]="dataSource"> -->
<ng-container header>
<tr>
<th bitCell>{{ "application" | i18n }}</th>
<th bitCell>{{ "atRiskPasswords" | i18n }}</th>
<th bitCell>{{ "totalPasswords" | i18n }}</th>
<th bitCell>{{ "atRiskMembers" | i18n }}</th>
<th bitCell>{{ "totalMembers" | i18n }}</th>
</tr>
</ng-container>
<!-- </bit-table> -->

View File

@@ -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<any>();
constructor() {
this.dataSource.data = [];
}
}

View File

@@ -0,0 +1,11 @@
<!-- <bit-table [dataSource]="dataSource"> -->
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "atRiskPasswords" | i18n }}</th>
<th bitCell>{{ "totalPasswords" | i18n }}</th>
<th bitCell>{{ "atRiskApplications" | i18n }}</th>
<th bitCell>{{ "totalApplications" | i18n }}</th>
</tr>
</ng-container>
<!-- </bit-table> -->

View File

@@ -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<any>();
constructor() {
this.dataSource.data = [];
}
}

View File

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

View File

@@ -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?"
},

View File

@@ -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,

View File

@@ -12,8 +12,7 @@
>
</bit-form-field>
<bit-form-field>
<bit-label *ngIf="!hasPassword">{{ "password" | i18n }}</bit-label>
<bit-label *ngIf="hasPassword">{{ "newPassword" | i18n }}</bit-label>
<bit-label>{{ "password" | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" />
<button
data-testid="toggle-visibility-for-password"