1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
Alec Rippberger
2024-10-10 10:07:39 -05:00
committed by GitHub
91 changed files with 2434 additions and 1087 deletions

View File

@@ -396,7 +396,7 @@
}, },
"generator": { "generator": {
"message": "Generator", "message": "Generator",
"description": "Short for 'Password Generator'." "description": "Short for 'credential generator'."
}, },
"passGenInfo": { "passGenInfo": {
"message": "Automatically generate strong, unique passwords for your logins." "message": "Automatically generate strong, unique passwords for your logins."
@@ -2516,7 +2516,25 @@
"message": "Send created successfully!", "message": "Send created successfully!",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." "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.", "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.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": { "placeholders": {
@@ -3680,6 +3698,9 @@
"noMatchingLoginsForSite": { "noMatchingLoginsForSite": {
"message": "No matching logins for this site" "message": "No matching logins for this site"
}, },
"searchSavePasskeyNewLogin": {
"message": "Search or save passkey as new login"
},
"confirm": { "confirm": {
"message": "Confirm" "message": "Confirm"
}, },

View File

@@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -24,6 +25,7 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou
describe("OverlayNotificationsBackground", () => { describe("OverlayNotificationsBackground", () => {
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let getFeatureFlagMock$: BehaviorSubject<boolean>;
let configService: MockProxy<ConfigService>; let configService: MockProxy<ConfigService>;
let notificationBackground: NotificationBackground; let notificationBackground: NotificationBackground;
let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
@@ -33,7 +35,10 @@ describe("OverlayNotificationsBackground", () => {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
logService = mock<LogService>(); logService = mock<LogService>();
configService = mock<ConfigService>(); getFeatureFlagMock$ = new BehaviorSubject(true);
configService = mock<ConfigService>({
getFeatureFlag$: jest.fn().mockReturnValue(getFeatureFlagMock$),
});
notificationBackground = mock<NotificationBackground>(); notificationBackground = mock<NotificationBackground>();
getEnableChangedPasswordPromptSpy = jest getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
@@ -164,8 +169,17 @@ describe("OverlayNotificationsBackground", () => {
}); });
describe("storing the modified login form data", () => { describe("storing the modified login form data", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } }); 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 () => { it("stores the modified login cipher form data", async () => {
sendMockExtensionMessage( sendMockExtensionMessage(
{ {
@@ -349,8 +363,14 @@ describe("OverlayNotificationsBackground", () => {
describe("web requests that trigger notifications", () => { describe("web requests that trigger notifications", () => {
const requestId = "123345"; const requestId = "123345";
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
beforeEach(async () => { beforeEach(async () => {
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage( sendMockExtensionMessage(
{ {
command: "formFieldSubmitted", command: "formFieldSubmitted",
@@ -446,6 +466,11 @@ describe("OverlayNotificationsBackground", () => {
it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
sender.tab = mock<chrome.tabs.Tab>({ id: 4 }); sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage( sendMockExtensionMessage(
{ {
command: "formFieldSubmitted", 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 { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -23,7 +24,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map(); private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map();
private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set();
private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map();
private featureFlagState$: Subscription;
private clearLoginCipherFormDataSubject: Subject<void> = new Subject(); private clearLoginCipherFormDataSubject: Subject<void> = new Subject();
private notificationFallbackTimeout: number | NodeJS.Timeout | null;
private readonly formSubmissionRequestMethods: Set<string> = new Set(["POST", "PUT", "PATCH"]); private readonly formSubmissionRequestMethods: Set<string> = new Set(["POST", "PUT", "PATCH"]);
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender), formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender),
@@ -41,19 +44,35 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
* Initialize the overlay notifications background service. * Initialize the overlay notifications background service.
*/ */
async init() { async init() {
const featureFlagActive = await this.configService.getFeatureFlag( this.featureFlagState$ = this.configService
FeatureFlag.NotificationBarAddLoginImprovements, .getFeatureFlag$(FeatureFlag.NotificationBarAddLoginImprovements)
); .pipe(startWith(undefined), pairwise())
if (!featureFlagActive) { .subscribe(([prev, current]) => this.handleInitFeatureFlagChange(prev, current));
return;
}
this.setupExtensionListeners();
this.clearLoginCipherFormDataSubject this.clearLoginCipherFormDataSubject
.pipe(switchMap(() => timer(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION))) .pipe(switchMap(() => timer(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION)))
.subscribe(() => this.modifyLoginCipherFormData.clear()); .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 * 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. * of the add login or change password notification if the conditions are met.
@@ -126,6 +145,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
message: OverlayNotificationsExtensionMessage, message: OverlayNotificationsExtensionMessage,
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
) => { ) => {
if (!this.websiteOriginsWithFields.has(sender.tab.id)) {
return;
}
const { uri, username, password, newPassword } = message; const { uri, username, password, newPassword } = message;
if (!username && !password && !newPassword) { if (!username && !password && !newPassword) {
return; return;
@@ -142,8 +165,29 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
} }
this.modifyLoginCipherFormData.set(sender.tab.id, formData); 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 * 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 * 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) => { private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => {
if ( if (
this.requestHostIsInvalid(details) || this.requestHostIsInvalid(details) ||
isInvalidResponseStatusCode(details.statusCode) ||
!this.activeFormSubmissionRequests.has(details.requestId) !this.activeFormSubmissionRequests.has(details.requestId)
) { ) {
return; return;
} }
if (isInvalidResponseStatusCode(details.statusCode)) {
this.clearNotificationFallbackTimeout();
return;
}
const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId);
if (!modifyLoginData) { if (!modifyLoginData) {
return; return;
@@ -335,6 +383,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
requestId: string, requestId: string,
modifyLoginData: ModifyLoginCipherFormData, modifyLoginData: ModifyLoginCipherFormData,
) => { ) => {
this.clearNotificationFallbackTimeout();
const tab = await BrowserApi.getTab(tabId); const tab = await BrowserApi.getTab(tabId);
if (tab.status !== "complete") { if (tab.status !== "complete") {
await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); 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. * Sets up the listeners for the extension messages and the tab events.
*/ */
private setupExtensionListeners() { private setupExtensionListeners() {
BrowserApi.messageListener("overlay-notifications", this.handleExtensionMessage); BrowserApi.addListener(chrome.runtime.onMessage, this.handleExtensionMessage);
chrome.tabs.onRemoved.addListener(this.handleTabRemoved); chrome.tabs.onRemoved.addListener(this.handleTabRemoved);
chrome.tabs.onUpdated.addListener(this.handleTabUpdated); 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. * 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 * Gets the user's authentication status from the auth service.
* status has changed, the inline menu button's authentication status will be updated
* and the inline menu list's ciphers will be updated.
*/ */
private async getAuthStatus() { private async getAuthStatus() {
return await firstValueFrom(this.authService.activeAccountStatus$); return await firstValueFrom(this.authService.activeAccountStatus$);

View File

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

View File

@@ -1,6 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction";
import { import {
@@ -59,6 +61,8 @@ describe("Fido2Background", () => {
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>; let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
let configServiceMock!: MockProxy<ConfigService>; let configServiceMock!: MockProxy<ConfigService>;
let enablePasskeysMock$!: BehaviorSubject<boolean>; let enablePasskeysMock$!: BehaviorSubject<boolean>;
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authServiceMock!: MockProxy<AuthService>;
let fido2Background!: Fido2Background; let fido2Background!: Fido2Background;
beforeEach(() => { beforeEach(() => {
@@ -81,6 +85,9 @@ describe("Fido2Background", () => {
vaultSettingsService.enablePasskeys$ = enablePasskeysMock$; vaultSettingsService.enablePasskeys$ = enablePasskeysMock$;
fido2ActiveRequestManager = mock<Fido2ActiveRequestManager>(); fido2ActiveRequestManager = mock<Fido2ActiveRequestManager>();
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true); fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authServiceMock = mock<AuthService>();
authServiceMock.activeAccountStatus$ = activeAccountStatusMock$;
fido2Background = new Fido2Background( fido2Background = new Fido2Background(
logService, logService,
fido2ActiveRequestManager, fido2ActiveRequestManager,
@@ -88,6 +95,7 @@ describe("Fido2Background", () => {
vaultSettingsService, vaultSettingsService,
scriptInjectorServiceMock, scriptInjectorServiceMock,
configServiceMock, configServiceMock,
authServiceMock,
); );
fido2Background["abortManager"] = abortManagerMock; fido2Background["abortManager"] = abortManagerMock;
abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) => abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) =>
@@ -101,55 +109,31 @@ describe("Fido2Background", () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe("injectFido2ContentScriptsInAllTabs", () => { describe("handleAuthStatusUpdate", () => {
it("does not inject any FIDO2 content scripts when no tabs have a secure url protocol", async () => { let updateContentScriptRegistrationSpy: jest.SpyInstance;
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
tabsQuerySpy.mockResolvedValueOnce([insecureTab]);
await fido2Background.injectFido2ContentScriptsInAllTabs(); beforeEach(() => {
updateContentScriptRegistrationSpy = jest
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled(); .spyOn(fido2Background as any, "updateContentScriptRegistration")
.mockImplementation();
}); });
it("only injects the FIDO2 content script into tabs that contain a secure url protocol", async () => { it("skips triggering the passkeys settings update if the user is logged out", async () => {
const secondTabMock = mock<chrome.tabs.Tab>({ id: 456, url: "https://example.com" }); activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
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]);
await fido2Background.injectFido2ContentScriptsInAllTabs(); fido2Background.init();
await flushPromises(); await flushPromises();
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ expect(updateContentScriptRegistrationSpy).not.toHaveBeenCalled();
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,
});
}); });
it("injects the `page-script.js` content script into the provided tab", async () => { it("triggers the passkeys setting update if the user is logged in", async () => {
tabsQuerySpy.mockResolvedValueOnce([tabMock]); activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
await fido2Background.injectFido2ContentScriptsInAllTabs(); fido2Background.init();
await flushPromises(); await flushPromises();
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ expect(updateContentScriptRegistrationSpy).toHaveBeenCalled();
tabId: tabMock.id,
injectDetails: sharedScriptInjectionDetails,
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
});
}); });
}); });
@@ -157,6 +141,7 @@ describe("Fido2Background", () => {
let portMock!: MockProxy<chrome.runtime.Port>; let portMock!: MockProxy<chrome.runtime.Port>;
beforeEach(() => { beforeEach(() => {
jest.spyOn(fido2Background as any, "handleAuthStatusUpdate").mockImplementation();
fido2Background.init(); fido2Background.init();
jest.spyOn(BrowserApi, "registerContentScriptsMv2"); jest.spyOn(BrowserApi, "registerContentScriptsMv2");
jest.spyOn(BrowserApi, "registerContentScriptsMv3"); jest.spyOn(BrowserApi, "registerContentScriptsMv3");
@@ -168,6 +153,15 @@ describe("Fido2Background", () => {
tabsQuerySpy.mockResolvedValue([tabMock]); 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 () => { it("does not destroy and re-inject the content scripts when triggering `handleEnablePasskeysUpdate` with an undefined currentEnablePasskeysSetting property", async () => {
await flushPromises(); await flushPromises();
@@ -421,6 +415,7 @@ describe("Fido2Background", () => {
let portMock!: MockProxy<chrome.runtime.Port>; let portMock!: MockProxy<chrome.runtime.Port>;
beforeEach(() => { beforeEach(() => {
jest.spyOn(fido2Background as any, "handleAuthStatusUpdate").mockImplementation();
fido2Background.init(); fido2Background.init();
portMock = createPortSpyMock(Fido2PortName.InjectedScript); portMock = createPortSpyMock(Fido2PortName.InjectedScript);
triggerRuntimeOnConnectEvent(portMock); 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 { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction";
@@ -29,6 +31,7 @@ import {
} from "./abstractions/fido2.background"; } from "./abstractions/fido2.background";
export class Fido2Background implements Fido2BackgroundInterface { export class Fido2Background implements Fido2BackgroundInterface {
private currentAuthStatus$: Subscription;
private abortManager = new AbortManager(); private abortManager = new AbortManager();
private fido2ContentScriptPortsSet = new Set<chrome.runtime.Port>(); private fido2ContentScriptPortsSet = new Set<chrome.runtime.Port>();
private registeredContentScripts: browser.contentScripts.RegisteredContentScript; private registeredContentScripts: browser.contentScripts.RegisteredContentScript;
@@ -55,6 +58,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
private vaultSettingsService: VaultSettingsService, private vaultSettingsService: VaultSettingsService,
private scriptInjectorService: ScriptInjectorService, private scriptInjectorService: ScriptInjectorService,
private configService: ConfigService, private configService: ConfigService,
private authService: AuthService,
) {} ) {}
/** /**
@@ -68,12 +72,32 @@ export class Fido2Background implements Fido2BackgroundInterface {
this.vaultSettingsService.enablePasskeys$ this.vaultSettingsService.enablePasskeys$
.pipe(startWith(undefined), pairwise()) .pipe(startWith(undefined), pairwise())
.subscribe(([previous, current]) => this.handleEnablePasskeysUpdate(previous, current)); .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. * Injects the FIDO2 content and page script into all existing browser tabs.
*/ */
async injectFido2ContentScriptsInAllTabs() { private async injectFido2ContentScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({}); const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) { 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 * 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 * 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, previousEnablePasskeysSetting: boolean,
enablePasskeys: boolean, enablePasskeys: boolean,
) { ) {
this.fido2ActiveRequestManager.removeAllActiveRequests(); if ((await this.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
await this.updateContentScriptRegistration(); return;
}
if (previousEnablePasskeysSetting === undefined) { if (previousEnablePasskeysSetting === undefined) {
return; return;
} }
this.fido2ActiveRequestManager.removeAllActiveRequests();
await this.updateContentScriptRegistration();
this.destroyLoadedFido2ContentScripts(); this.destroyLoadedFido2ContentScripts();
if (enablePasskeys) { if (enablePasskeys) {
void this.injectFido2ContentScriptsInAllTabs(); void this.injectFido2ContentScriptsInAllTabs();

View File

@@ -9,6 +9,7 @@
const script = globalContext.document.createElement("script"); const script = globalContext.document.createElement("script");
script.src = chrome.runtime.getURL("content/fido2-page-script.js"); script.src = chrome.runtime.getURL("content/fido2-page-script.js");
script.async = false;
const scriptInsertionPoint = const scriptInsertionPoint =
globalContext.document.head || globalContext.document.documentElement; globalContext.document.head || globalContext.document.documentElement;

View File

@@ -9,6 +9,7 @@
const script = globalContext.document.createElement("script"); const script = globalContext.document.createElement("script");
script.src = chrome.runtime.getURL("content/fido2-page-script.js"); 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 // 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 // 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"; import { Messenger } from "./messaging/messenger";
(function (globalContext) { (function (globalContext) {
if (globalContext.document.currentScript) {
globalContext.document.currentScript.parentNode.removeChild(
globalContext.document.currentScript,
);
}
const shouldExecuteContentScript = const shouldExecuteContentScript =
globalContext.document.contentType === "text/html" && globalContext.document.contentType === "text/html" &&
(globalContext.document.location.protocol === "https:" || (globalContext.document.location.protocol === "https:" ||

View File

@@ -50,7 +50,7 @@
<ng-container *ngIf="!displayedCiphers.length"> <ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon"> <bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">{{ "noMatchingLoginsForSite" | i18n }}</ng-container> <ng-container slot="title">{{ "noMatchingLoginsForSite" | i18n }}</ng-container>
<ng-container slot="description">Search or save passkey as new login</ng-container> <ng-container slot="description">{{ "searchSavePasskeyNewLogin" | i18n }}</ng-container>
<button <button
bitButton bitButton
buttonType="primary" buttonType="primary"
@@ -100,8 +100,8 @@
<!-- Display when no matching ciphers exist --> <!-- Display when no matching ciphers exist -->
<ng-container *ngIf="!displayedCiphers.length"> <ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon"> <bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">No matching logins for this site</ng-container> <ng-container slot="title">{{ "noItemsMatchSearch" | i18n }}</ng-container>
<ng-container slot="description">Search or save passkey as new login</ng-container> <ng-container slot="description">{{ "clearFiltersOrTryAnother" | i18n }}</ng-container>
<button <button
bitButton bitButton
buttonType="primary" buttonType="primary"

View File

@@ -7,6 +7,27 @@ import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-
export class DomQueryService implements DomQueryServiceInterface { export class DomQueryService implements DomQueryServiceInterface {
private pageContainsShadowDom: boolean; private pageContainsShadowDom: boolean;
private useTreeWalkerStrategyFlagSet = true; private useTreeWalkerStrategyFlagSet = true;
private ignoredTreeWalkerNodes = new Set([
"svg",
"script",
"noscript",
"head",
"style",
"link",
"meta",
"title",
"base",
"img",
"picture",
"video",
"audio",
"object",
"source",
"track",
"param",
"map",
"area",
]);
constructor() { constructor() {
void this.init(); void this.init();
@@ -21,6 +42,7 @@ export class DomQueryService implements DomQueryServiceInterface {
* @param treeWalkerFilter - The filter callback to use for the treeWalker query * @param treeWalkerFilter - The filter callback to use for the treeWalker query
* @param mutationObserver - The MutationObserver to use for observing shadow roots * @param mutationObserver - The MutationObserver to use for observing shadow roots
* @param forceDeepQueryAttempt - Whether to force a deep query attempt * @param forceDeepQueryAttempt - Whether to force a deep query attempt
* @param ignoredTreeWalkerNodesOverride - An optional set of node names to ignore when using the treeWalker strategy
*/ */
query<T>( query<T>(
root: Document | ShadowRoot | Element, root: Document | ShadowRoot | Element,
@@ -28,15 +50,28 @@ export class DomQueryService implements DomQueryServiceInterface {
treeWalkerFilter: CallableFunction, treeWalkerFilter: CallableFunction,
mutationObserver?: MutationObserver, mutationObserver?: MutationObserver,
forceDeepQueryAttempt?: boolean, forceDeepQueryAttempt?: boolean,
ignoredTreeWalkerNodesOverride?: Set<string>,
): T[] { ): T[] {
const ignoredTreeWalkerNodes = ignoredTreeWalkerNodesOverride || this.ignoredTreeWalkerNodes;
if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) { if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) {
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver); return this.queryAllTreeWalkerNodes<T>(
root,
treeWalkerFilter,
ignoredTreeWalkerNodes,
mutationObserver,
);
} }
try { try {
return this.deepQueryElements<T>(root, queryString, mutationObserver); return this.deepQueryElements<T>(root, queryString, mutationObserver);
} catch { } catch {
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver); return this.queryAllTreeWalkerNodes<T>(
root,
treeWalkerFilter,
ignoredTreeWalkerNodes,
mutationObserver,
);
} }
} }
@@ -207,11 +242,13 @@ export class DomQueryService implements DomQueryServiceInterface {
* and returns a collection of nodes. * and returns a collection of nodes.
* @param rootNode * @param rootNode
* @param filterCallback * @param filterCallback
* @param ignoredTreeWalkerNodes
* @param mutationObserver * @param mutationObserver
*/ */
private queryAllTreeWalkerNodes<T>( private queryAllTreeWalkerNodes<T>(
rootNode: Node, rootNode: Node,
filterCallback: CallableFunction, filterCallback: CallableFunction,
ignoredTreeWalkerNodes: Set<string>,
mutationObserver?: MutationObserver, mutationObserver?: MutationObserver,
): T[] { ): T[] {
const treeWalkerQueryResults: T[] = []; const treeWalkerQueryResults: T[] = [];
@@ -220,6 +257,7 @@ export class DomQueryService implements DomQueryServiceInterface {
rootNode, rootNode,
treeWalkerQueryResults, treeWalkerQueryResults,
filterCallback, filterCallback,
ignoredTreeWalkerNodes,
mutationObserver, mutationObserver,
); );
@@ -233,15 +271,21 @@ export class DomQueryService implements DomQueryServiceInterface {
* @param rootNode * @param rootNode
* @param treeWalkerQueryResults * @param treeWalkerQueryResults
* @param filterCallback * @param filterCallback
* @param ignoredTreeWalkerNodes
* @param mutationObserver * @param mutationObserver
*/ */
private buildTreeWalkerNodesQueryResults<T>( private buildTreeWalkerNodesQueryResults<T>(
rootNode: Node, rootNode: Node,
treeWalkerQueryResults: T[], treeWalkerQueryResults: T[],
filterCallback: CallableFunction, filterCallback: CallableFunction,
ignoredTreeWalkerNodes: Set<string>,
mutationObserver?: MutationObserver, mutationObserver?: MutationObserver,
) { ) {
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT); const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT, (node) =>
ignoredTreeWalkerNodes.has(node.nodeName?.toLowerCase())
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
);
let currentNode = treeWalker?.currentNode; let currentNode = treeWalker?.currentNode;
while (currentNode) { while (currentNode) {
@@ -263,6 +307,7 @@ export class DomQueryService implements DomQueryServiceInterface {
nodeShadowRoot, nodeShadowRoot,
treeWalkerQueryResults, treeWalkerQueryResults,
filterCallback, filterCallback,
ignoredTreeWalkerNodes,
mutationObserver, mutationObserver,
); );
} }

View File

@@ -1103,6 +1103,7 @@ export default class MainBackground {
this.vaultSettingsService, this.vaultSettingsService,
this.scriptInjectorService, this.scriptInjectorService,
this.configService, this.configService,
this.authService,
); );
const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService); const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService);
@@ -1118,7 +1119,6 @@ export default class MainBackground {
this.messagingService, this.messagingService,
this.logService, this.logService,
this.configService, this.configService,
this.fido2Background,
messageListener, messageListener,
this.accountService, this.accountService,
lockService, lockService,

View File

@@ -21,7 +21,6 @@ import {
openTwoFactorAuthPopout, openTwoFactorAuthPopout,
} from "../auth/popup/utils/auth-popout-window"; } from "../auth/popup/utils/auth-popout-window";
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background"; 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 { AutofillService } from "../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
@@ -46,7 +45,6 @@ export default class RuntimeBackground {
private messagingService: MessagingService, private messagingService: MessagingService,
private logService: LogService, private logService: LogService,
private configService: ConfigService, private configService: ConfigService,
private fido2Background: Fido2Background,
private messageListener: MessageListener, private messageListener: MessageListener,
private accountService: AccountService, private accountService: AccountService,
private readonly lockService: LockService, private readonly lockService: LockService,
@@ -365,7 +363,6 @@ export default class RuntimeBackground {
private async checkOnInstalled() { private async checkOnInstalled() {
setTimeout(async () => { setTimeout(async () => {
void this.fido2Background.injectFido2ContentScriptsInAllTabs();
void this.autofillService.loadAutofillScriptsOnInstall(); void this.autofillService.loadAutofillScriptsOnInstall();
if (this.onInstalledReason != null) { if (this.onInstalledReason != null) {

View File

@@ -43,23 +43,17 @@ function buildRegisterContentScriptsPolyfill() {
function NestedProxy<T extends object>(target: T): T { function NestedProxy<T extends object>(target: T): T {
return new Proxy(target, { return new Proxy(target, {
get(target, prop) { get(target, prop) {
const propertyValue = target[prop as keyof T]; if (!target[prop as keyof T]) {
if (!propertyValue) {
return; return;
} }
if (typeof propertyValue === "object") { if (typeof target[prop as keyof T] !== "function") {
return NestedProxy<typeof propertyValue>(propertyValue); return NestedProxy(target[prop as keyof T] as object);
}
if (typeof propertyValue !== "function") {
return propertyValue;
} }
return (...arguments_: any[]) => return (...arguments_: any[]) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
propertyValue(...arguments_, (result: any) => { (target[prop as keyof T] as CallableFunction)(...arguments_, (result: any) => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message)); reject(new Error(chrome.runtime.lastError.message));
} else { } else {

View File

@@ -12,7 +12,6 @@ import {
unauthGuardFn, unauthGuardFn,
} from "@bitwarden/angular/auth/guards"; } from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
import { import {
@@ -582,7 +581,7 @@ const routes: Routes = [
canDeactivate: [clearVaultStateGuard], canDeactivate: [clearVaultStateGuard],
data: { state: "tabs_vault" } satisfies RouteDataProperties, data: { state: "tabs_vault" } satisfies RouteDataProperties,
}), }),
...generatorSwap(GeneratorComponent, CredentialGeneratorComponent, { ...extensionRefreshSwap(GeneratorComponent, CredentialGeneratorComponent, {
path: "generator", path: "generator",
canActivate: [authGuard], canActivate: [authGuard],
data: { state: "tabs_generator" } satisfies RouteDataProperties, data: { state: "tabs_generator" } satisfies RouteDataProperties,

View File

@@ -1,2 +1,15 @@
<!-- Note: this is all throwaway markup, so it won't follow best practices --> <popup-page>
<tools-username-generator /> <popup-header slot="header" [pageTitle]="'generator' | i18n">
<ng-container slot="end">
<app-pop-out />
<app-current-account />
</ng-container>
</popup-header>
<tools-credential-generator />
<bit-item>
<a type="button" bit-item-content routerLink="/generator-history">
{{ "passwordHistory" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
</popup-page>

View File

@@ -1,12 +1,28 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { SectionComponent } from "@bitwarden/components"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UsernameGeneratorComponent } from "@bitwarden/generator-components"; import { ItemModule } from "@bitwarden/components";
import { GeneratorModule } from "@bitwarden/generator-components";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@Component({ @Component({
standalone: true, standalone: true,
selector: "credential-generator", selector: "credential-generator",
templateUrl: "credential-generator.component.html", templateUrl: "credential-generator.component.html",
imports: [UsernameGeneratorComponent, SectionComponent], imports: [
GeneratorModule,
CurrentAccountComponent,
JslibModule,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
PopupFooterComponent,
ItemModule,
],
}) })
export class CredentialGeneratorComponent {} export class CredentialGeneratorComponent {}

View File

@@ -16,7 +16,9 @@
> >
<bit-icon [icon]="sendCreatedIcon"></bit-icon> <bit-icon [icon]="sendCreatedIcon"></bit-icon>
<h3 class="tw-font-semibold">{{ "createdSendSuccessfully" | i18n }}</h3> <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()"> <button bitButton type="button" buttonType="primary" (click)="copyLink()">
<b>{{ "copyLink" | i18n }}</b> <b>{{ "copyLink" | i18n }}</b>
</button> </button>

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing"; import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; 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 { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@@ -26,7 +26,6 @@ import { SendCreatedComponent } from "./send-created.component";
describe("SendCreatedComponent", () => { describe("SendCreatedComponent", () => {
let component: SendCreatedComponent; let component: SendCreatedComponent;
let fixture: ComponentFixture<SendCreatedComponent>; let fixture: ComponentFixture<SendCreatedComponent>;
let i18nService: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>; let platformUtilsService: MockProxy<PlatformUtilsService>;
let sendService: MockProxy<SendService>; let sendService: MockProxy<SendService>;
let toastService: MockProxy<ToastService>; let toastService: MockProxy<ToastService>;
@@ -36,17 +35,10 @@ describe("SendCreatedComponent", () => {
let router: MockProxy<Router>; let router: MockProxy<Router>;
const sendId = "test-send-id"; const sendId = "test-send-id";
const deletionDate = new Date(); let sendView: SendView;
deletionDate.setDate(deletionDate.getDate() + 7); let sendViewsSubject: BehaviorSubject<SendView[]>;
const sendView: SendView = {
id: sendId,
deletionDate,
accessId: "abc",
urlB64Key: "123",
} as SendView;
beforeEach(async () => { beforeEach(async () => {
i18nService = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>(); platformUtilsService = mock<PlatformUtilsService>();
sendService = mock<SendService>(); sendService = mock<SendService>();
toastService = mock<ToastService>(); toastService = mock<ToastService>();
@@ -54,6 +46,17 @@ describe("SendCreatedComponent", () => {
activatedRoute = mock<ActivatedRoute>(); activatedRoute = mock<ActivatedRoute>();
environmentService = mock<EnvironmentService>(); environmentService = mock<EnvironmentService>();
router = mock<Router>(); 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$", { Object.defineProperty(environmentService, "environment$", {
configurable: true, configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
@@ -65,8 +68,6 @@ describe("SendCreatedComponent", () => {
}, },
} as any; } as any;
sendService.sendViews$ = of([sendView]);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
CommonModule, CommonModule,
@@ -82,7 +83,25 @@ describe("SendCreatedComponent", () => {
SendCreatedComponent, SendCreatedComponent,
], ],
providers: [ 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: PlatformUtilsService, useValue: platformUtilsService },
{ provide: SendService, useValue: sendService }, { provide: SendService, useValue: sendService },
{ provide: ToastService, useValue: toastService }, { provide: ToastService, useValue: toastService },
@@ -94,40 +113,73 @@ describe("SendCreatedComponent", () => {
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
], ],
}).compileComponents(); }).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SendCreatedComponent); fixture = TestBed.createComponent(SendCreatedComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges();
}); });
it("should create", () => { it("should create", () => {
fixture.detectChanges();
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it("should initialize send and daysAvailable", () => { it("should initialize send, daysAvailable, and hoursAvailable", () => {
fixture.detectChanges();
expect(component["send"]).toBe(sendView); 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 () => { it("should navigate back to send list on close", async () => {
fixture.detectChanges();
await component.close(); await component.close();
expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]); expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]);
}); });
describe("getDaysAvailable", () => { describe("getHoursAvailable", () => {
it("returns the correct number of days", () => { it("returns the correct number of hours", () => {
sendView.deletionDate.setDate(sendView.deletionDate.getDate() + 7);
sendViewsSubject.next([sendView]);
fixture.detectChanges(); 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", () => { describe("copyLink", () => {
it("should copy link and show toast", async () => { it("should copy link and show toast", async () => {
fixture.detectChanges();
const link = "https://example.com/#/send/abc/123"; const link = "https://example.com/#/send/abc/123";
await component.copyLink(); await component.copyLink();
@@ -136,7 +188,7 @@ describe("SendCreatedComponent", () => {
expect(toastService.showToast).toHaveBeenCalledWith({ expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success", variant: "success",
title: null, title: null,
message: i18nService.t("sendLinkCopied"), message: "sendLinkCopied",
}); });
}); });
}); });

View File

@@ -39,6 +39,7 @@ export class SendCreatedComponent {
protected sendCreatedIcon = SendCreatedIcon; protected sendCreatedIcon = SendCreatedIcon;
protected send: SendView; protected send: SendView;
protected daysAvailable = 0; protected daysAvailable = 0;
protected hoursAvailable = 0;
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
@@ -54,14 +55,26 @@ export class SendCreatedComponent {
this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => { this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => {
this.send = sendViews.find((s) => s.id === sendId); this.send = sendViews.find((s) => s.id === sendId);
if (this.send) { 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(); 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() { async close() {

View File

@@ -304,9 +304,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.1.24" version = "1.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" checksum = "2e80e3b6a3ab07840e1cae9b0666a63970dc28e8ed5ffbcdacbfc760c281bfc1"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@@ -725,9 +725,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@@ -735,15 +735,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@@ -752,9 +752,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]] [[package]]
name = "futures-lite" name = "futures-lite"
@@ -771,9 +771,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -782,21 +782,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -936,9 +936,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
[[package]] [[package]]
name = "heck" name = "heck"
@@ -975,9 +975,9 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.5.0" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown",
@@ -1378,21 +1378,18 @@ dependencies = [
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.4" version = "0.36.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.20.1" version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
@@ -1503,12 +1500,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "portable-atomic"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2"
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -1535,9 +1526,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.86" version = "1.0.87"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]

View File

@@ -1,8 +1,7 @@
<bit-dialog #dialog dialogSize="large" background="alt"> <bit-dialog #dialog dialogSize="large" background="alt">
<span bitDialogTitle>{{ "generator" | i18n }}</span> <span bitDialogTitle>{{ "generator" | i18n }}</span>
<ng-container bitDialogContent> <ng-container bitDialogContent>
<!-- FIXME: Will get replaced with <tools-credential-generator /> once https://github.com/bitwarden/clients/pull/11398 has been merged --> <tools-credential-generator />
<tools-password-generator />
</ng-container> </ng-container>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose> <button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>

View File

@@ -2,12 +2,12 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule } from "@bitwarden/components"; import { ButtonModule, DialogModule } from "@bitwarden/components";
import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; import { GeneratorModule } from "@bitwarden/generator-components";
@Component({ @Component({
standalone: true, standalone: true,
selector: "credential-generator", selector: "credential-generator",
templateUrl: "credential-generator.component.html", templateUrl: "credential-generator.component.html",
imports: [DialogModule, ButtonModule, JslibModule, PasswordGeneratorComponent], imports: [DialogModule, ButtonModule, JslibModule, GeneratorModule],
}) })
export class CredentialGeneratorComponent {} export class CredentialGeneratorComponent {}

View File

@@ -2340,7 +2340,8 @@
"message": "Unlocked" "message": "Unlocked"
}, },
"generator": { "generator": {
"message": "Generator" "message": "Generator",
"description": "Short for 'credential generator'."
}, },
"whatWouldYouLikeToGenerate": { "whatWouldYouLikeToGenerate": {
"message": "What would you like to generate?" "message": "What would you like to generate?"

View File

@@ -14,6 +14,7 @@
} }
}, },
"../desktop_native/napi": { "../desktop_native/napi": {
"name": "@bitwarden/desktop-napi",
"version": "0.1.0", "version": "0.1.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@bitwarden/web-vault", "name": "@bitwarden/web-vault",
"version": "2024.10.1", "version": "2024.10.2",
"scripts": { "scripts": {
"build:oss": "webpack", "build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

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

View File

@@ -51,6 +51,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
showPaymentAndHistory$: Observable<boolean>; showPaymentAndHistory$: Observable<boolean>;
hideNewOrgButton$: Observable<boolean>; hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>; organizationIsUnmanaged$: Observable<boolean>;
isAccessIntelligenceFeatureEnabled = false;
private _destroy = new Subject<void>(); private _destroy = new Subject<void>();
@@ -70,6 +71,10 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
document.body.classList.remove("layout_frontend"); document.body.classList.remove("layout_frontend");
this.isAccessIntelligenceFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.AccessIntelligence,
);
this.organization$ = this.route.params this.organization$ = this.route.params
.pipe(takeUntil(this._destroy)) .pipe(takeUntil(this._destroy))
.pipe<string>(map((p) => p.organizationId)) .pipe<string>(map((p) => p.organizationId))

View File

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

View File

@@ -1,16 +1,9 @@
import { import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
Component, import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
EventEmitter, import { FormBuilder, Validators } from "@angular/forms";
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { Subject, takeUntil } from "rxjs"; 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 { 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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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"; 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({ @Component({
selector: "app-reset-password", selector: "app-reset-password",
templateUrl: "reset-password.component.html", 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 { export class ResetPasswordComponent implements OnInit, OnDestroy {
@Input() name: string; formGroup = this.formBuilder.group({
@Input() email: string; newPassword: ["", Validators.required],
@Input() id: string; });
@Input() organizationId: string;
@Output() passwordReset = new EventEmitter(); @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component;
@ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent;
enforcedPolicyOptions: MasterPasswordPolicyOptions; enforcedPolicyOptions: MasterPasswordPolicyOptions;
newPassword: string = null;
showPassword = false; showPassword = false;
passwordStrengthResult: zxcvbn.ZXCVBNResult; passwordStrengthScore: number;
formPromise: Promise<any>;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
constructor( constructor(
@Inject(DIALOG_DATA) protected data: ResetPasswordDialogData,
private resetPasswordService: OrganizationUserResetPasswordService, private resetPasswordService: OrganizationUserResetPasswordService,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
@@ -51,6 +77,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
private logService: LogService, private logService: LogService,
private dialogService: DialogService, private dialogService: DialogService,
private toastService: ToastService, private toastService: ToastService,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<ResetPasswordDialogResult>,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -69,13 +97,15 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
} }
get loggedOutWarningName() { 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() { async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.newPassword = await this.passwordGenerationService.generatePassword(options); this.formGroup.patchValue({
this.passwordStrengthComponent.updatePasswordStrength(this.newPassword); newPassword: await this.passwordGenerationService.generatePassword(options),
});
this.passwordStrengthComponent.updatePasswordStrength(this.formGroup.value.newPassword);
} }
togglePassword() { togglePassword() {
@@ -83,7 +113,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
document.getElementById("newPassword").focus(); document.getElementById("newPassword").focus();
} }
copy(value: string) { copy() {
const value = this.formGroup.value.newPassword;
if (value == null) { if (value == null) {
return; return;
} }
@@ -96,9 +127,9 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
}); });
} }
async submit() { submit = async () => {
// Validation // Validation
if (this.newPassword == null || this.newPassword === "") { if (this.formGroup.value.newPassword == null || this.formGroup.value.newPassword === "") {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: this.i18nService.t("errorOccurred"), title: this.i18nService.t("errorOccurred"),
@@ -107,7 +138,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
return false; return false;
} }
if (this.newPassword.length < Utils.minimumPasswordLength) { if (this.formGroup.value.newPassword.length < Utils.minimumPasswordLength) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: this.i18nService.t("errorOccurred"), title: this.i18nService.t("errorOccurred"),
@@ -119,8 +150,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
if ( if (
this.enforcedPolicyOptions != null && this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword( !this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score, this.passwordStrengthScore,
this.newPassword, this.formGroup.value.newPassword,
this.enforcedPolicyOptions, this.enforcedPolicyOptions,
) )
) { ) {
@@ -132,7 +163,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
return; return;
} }
if (this.passwordStrengthResult.score < 3) { if (this.passwordStrengthScore < 3) {
const result = await this.dialogService.openSimpleDialog({ const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" }, title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" }, content: { key: "weakMasterPasswordDesc" },
@@ -145,26 +176,29 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
} }
try { try {
this.formPromise = this.resetPasswordService.resetMasterPassword( await this.resetPasswordService.resetMasterPassword(
this.newPassword, this.formGroup.value.newPassword,
this.email, this.data.email,
this.id, this.data.id,
this.organizationId, this.data.organizationId,
); );
await this.formPromise;
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: null,
message: this.i18nService.t("resetPasswordSuccess"), message: this.i18nService.t("resetPasswordSuccess"),
}); });
this.passwordReset.emit();
} catch (e) { } catch (e) {
this.logService.error(e); this.logService.error(e);
} }
this.formPromise = null;
this.dialogRef.close(ResetPasswordDialogResult.Ok);
};
getStrengthScore(result: number) {
this.passwordStrengthScore = result;
} }
getStrengthResult(result: zxcvbn.ZXCVBNResult) { static open = (dialogService: DialogService, input: DialogConfig<ResetPasswordDialogData>) => {
this.passwordStrengthResult = result; return dialogService.open<ResetPasswordDialogResult>(ResetPasswordComponent, input);
} };
} }

View File

@@ -70,7 +70,10 @@ import {
MemberDialogTab, MemberDialogTab,
openUserAddEditDialog, openUserAddEditDialog,
} from "./components/member-dialog"; } from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component"; import {
ResetPasswordComponent,
ResetPasswordDialogResult,
} from "./components/reset-password.component";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> { class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType; protected statusType = OrganizationUserStatusType;
@@ -663,24 +666,19 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
} }
async resetPassword(user: OrganizationUserView) { async resetPassword(user: OrganizationUserView) {
const [modal] = await this.modalService.openViewRef( const dialogRef = ResetPasswordComponent.open(this.dialogService, {
ResetPasswordComponent, data: {
this.resetPasswordModalRef, name: this.userNamePipe.transform(user),
(comp) => { email: user != null ? user.email : null,
comp.name = this.userNamePipe.transform(user); organizationId: this.organization.id,
comp.email = user != null ? user.email : null; id: user != null ? user.id : 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 result = await lastValueFrom(dialogRef.closed);
if (result === ResetPasswordDialogResult.Ok) {
await this.load();
}
} }
protected async removeUserConfirmationDialog(user: OrganizationUserView) { protected async removeUserConfirmationDialog(user: OrganizationUserView) {

View File

@@ -1,6 +1,7 @@
import { ScrollingModule } from "@angular/cdk/scrolling"; import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { LooseComponentsModule } from "../../../shared"; import { LooseComponentsModule } from "../../../shared";
@@ -24,6 +25,7 @@ import { MembersComponent } from "./members.component";
UserDialogModule, UserDialogModule,
PasswordCalloutComponent, PasswordCalloutComponent,
ScrollingModule, ScrollingModule,
PasswordStrengthV2Component,
], ],
declarations: [ declarations: [
BulkConfirmComponent, BulkConfirmComponent,

View File

@@ -62,6 +62,13 @@ const routes: Routes = [
(m) => m.OrganizationReportingModule, (m) => m.OrganizationReportingModule,
), ),
}, },
{
path: "access-intelligence",
loadChildren: () =>
import("../../tools/access-intelligence/access-intelligence.module").then(
(m) => m.AccessIntelligenceModule,
),
},
{ {
path: "billing", path: "billing",
loadChildren: () => loadChildren: () =>

View File

@@ -12,11 +12,14 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction";
import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response"; import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { HttpStatusCode } from "@bitwarden/common/enums"; import { HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -107,13 +110,24 @@ export class SsoComponent extends BaseSsoComponent implements OnInit {
// show loading spinner // show loading spinner
this.loggingIn = true; this.loggingIn = true;
try { try {
const response: OrganizationDomainSsoDetailsResponse = if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) {
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email); const response: ListResponse<VerifiedOrganizationDomainSsoDetailsResponse> =
await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email);
if (response?.ssoAvailable && response?.verifiedDate) { if (response.data.length > 0) {
this.identifierFormControl.setValue(response.organizationIdentifier); this.identifierFormControl.setValue(response.data[0].organizationIdentifier);
await this.submit(); await this.submit();
return; return;
}
} else {
const response: OrganizationDomainSsoDetailsResponse =
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email);
if (response?.ssoAvailable && response?.verifiedDate) {
this.identifierFormControl.setValue(response.organizationIdentifier);
await this.submit();
return;
}
} }
} catch (error) { } catch (error) {
this.handleGetClaimedDomainByEmailError(error); this.handleGetClaimedDomainByEmailError(error);

View File

@@ -11,8 +11,12 @@
></i> ></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="invoices || transactions"> <ng-container *ngIf="openInvoices || paidInvoices || transactions">
<app-billing-history [invoices]="invoices" [transactions]="transactions"></app-billing-history> <app-billing-history
[openInvoices]="openInvoices"
[paidInvoices]="paidInvoices"
[transactions]="transactions"
></app-billing-history>
<button <button
type="button" type="button"
bitButton bitButton

View File

@@ -14,7 +14,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
export class BillingHistoryViewComponent implements OnInit { export class BillingHistoryViewComponent implements OnInit {
loading = false; loading = false;
firstLoaded = false; firstLoaded = false;
invoices: BillingInvoiceResponse[] = []; openInvoices: BillingInvoiceResponse[] = [];
paidInvoices: BillingInvoiceResponse[] = [];
transactions: BillingTransactionResponse[] = []; transactions: BillingTransactionResponse[] = [];
hasAdditionalHistory: boolean = false; hasAdditionalHistory: boolean = false;
@@ -41,8 +42,14 @@ export class BillingHistoryViewComponent implements OnInit {
} }
this.loading = true; this.loading = true;
const invoicesPromise = this.accountBillingApiService.getBillingInvoices( const openInvoicesPromise = this.accountBillingApiService.getBillingInvoices(
this.invoices.length > 0 ? this.invoices[this.invoices.length - 1].id : null, "open",
this.openInvoices.length > 0 ? this.openInvoices[this.openInvoices.length - 1].id : null,
);
const paidInvoicesPromise = this.accountBillingApiService.getBillingInvoices(
"paid",
this.paidInvoices.length > 0 ? this.paidInvoices[this.paidInvoices.length - 1].id : null,
); );
const transactionsPromise = this.accountBillingApiService.getBillingTransactions( const transactionsPromise = this.accountBillingApiService.getBillingTransactions(
@@ -51,15 +58,20 @@ export class BillingHistoryViewComponent implements OnInit {
: null, : null,
); );
const accountInvoices = await invoicesPromise; const openInvoices = await openInvoicesPromise;
const accountTransactions = await transactionsPromise; const paidInvoices = await paidInvoicesPromise;
const transactions = await transactionsPromise;
const pageSize = 5; const pageSize = 5;
this.invoices = [...this.invoices, ...accountInvoices]; this.openInvoices = [...this.openInvoices, ...openInvoices];
this.transactions = [...this.transactions, ...accountTransactions]; this.paidInvoices = [...this.paidInvoices, ...paidInvoices];
this.hasAdditionalHistory = !( this.transactions = [...this.transactions, ...transactions];
accountInvoices.length < pageSize && accountTransactions.length < pageSize
); this.hasAdditionalHistory =
openInvoices.length >= pageSize ||
paidInvoices.length >= pageSize ||
transactions.length >= pageSize;
this.loading = false; this.loading = false;
} }

View File

@@ -9,8 +9,12 @@
></i> ></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="invoices || transactions"> <ng-container *ngIf="openInvoices || paidInvoices || transactions">
<app-billing-history [invoices]="invoices" [transactions]="transactions"></app-billing-history> <app-billing-history
[openInvoices]="openInvoices"
[paidInvoices]="paidInvoices"
[transactions]="transactions"
></app-billing-history>
<button <button
type="button" type="button"
bitButton bitButton

View File

@@ -14,7 +14,8 @@ import {
export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy { export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy {
loading = false; loading = false;
firstLoaded = false; firstLoaded = false;
invoices: BillingInvoiceResponse[] = []; openInvoices: BillingInvoiceResponse[] = [];
paidInvoices: BillingInvoiceResponse[] = [];
transactions: BillingTransactionResponse[] = []; transactions: BillingTransactionResponse[] = [];
organizationId: string; organizationId: string;
hasAdditionalHistory: boolean = false; hasAdditionalHistory: boolean = false;
@@ -51,9 +52,16 @@ export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy {
this.loading = true; this.loading = true;
const invoicesPromise = this.organizationBillingApiService.getBillingInvoices( const openInvoicesPromise = this.organizationBillingApiService.getBillingInvoices(
this.organizationId, this.organizationId,
this.invoices.length > 0 ? this.invoices[this.invoices.length - 1].id : null, "open",
this.openInvoices.length > 0 ? this.openInvoices[this.openInvoices.length - 1].id : null,
);
const paidInvoicesPromise = this.organizationBillingApiService.getBillingInvoices(
this.organizationId,
"paid",
this.paidInvoices.length > 0 ? this.paidInvoices[this.paidInvoices.length - 1].id : null,
); );
const transactionsPromise = this.organizationBillingApiService.getBillingTransactions( const transactionsPromise = this.organizationBillingApiService.getBillingTransactions(
@@ -63,13 +71,21 @@ export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy {
: null, : null,
); );
const invoices = await invoicesPromise; const openInvoices = await openInvoicesPromise;
const paidInvoices = await paidInvoicesPromise;
const transactions = await transactionsPromise; const transactions = await transactionsPromise;
const pageSize = 5; const pageSize = 5;
this.invoices = [...this.invoices, ...invoices]; this.openInvoices = [...this.openInvoices, ...openInvoices];
this.paidInvoices = [...this.paidInvoices, ...paidInvoices];
this.transactions = [...this.transactions, ...transactions]; this.transactions = [...this.transactions, ...transactions];
this.hasAdditionalHistory = !(invoices.length < pageSize && transactions.length < pageSize);
this.hasAdditionalHistory =
openInvoices.length <= pageSize ||
paidInvoices.length <= pageSize ||
transactions.length <= pageSize;
this.loading = false; this.loading = false;
} }
} }

View File

@@ -1,9 +1,11 @@
<bit-section> <bit-section>
<h3 bitTypography="h3">{{ "invoices" | i18n }}</h3> <h3 bitTypography="h3">{{ "unpaid" | i18n }} {{ "invoices" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p> <p bitTypography="body1" *ngIf="!openInvoices || !openInvoices.length">
{{ "noUnpaidInvoices" | i18n }}
</p>
<bit-table> <bit-table>
<ng-template body> <ng-template body>
<tr bitRow *ngFor="let i of invoices"> <tr bitRow *ngFor="let i of openInvoices">
<td bitCell>{{ i.date | date: "mediumDate" }}</td> <td bitCell>{{ i.date | date: "mediumDate" }}</td>
<td bitCell> <td bitCell>
<a <a
@@ -26,7 +28,51 @@
> >
</td> </td>
<td bitCell>{{ i.amount | currency: "$" }}</td> <td bitCell>{{ i.amount | currency: "$" }}</td>
<td bitCell class="tw-w-28">
<span *ngIf="i.paid">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>
</tr>
</ng-template>
</bit-table>
</bit-section>
<bit-section>
<h3 bitTypography="h3">{{ "paid" | i18n }} {{ "invoices" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!paidInvoices || !paidInvoices.length">
{{ "noPaidInvoices" | i18n }}
</p>
<bit-table>
<ng-template body>
<tr bitRow *ngFor="let i of paidInvoices">
<td bitCell>{{ i.date | date: "mediumDate" }}</td>
<td bitCell> <td bitCell>
<a
href="{{ i.pdfUrl }}"
target="_blank"
rel="noreferrer"
class="tw-mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a
bitLink
href="{{ i.url }}"
target="_blank"
rel="noreferrer"
title="{{ 'viewInvoice' | i18n }}"
>
{{ "invoiceNumber" | i18n: i.number }}</a
>
</td>
<td bitCell>{{ i.amount | currency: "$" }}</td>
<td bitCell class="tw-w-28">
<span *ngIf="i.paid"> <span *ngIf="i.paid">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n }} {{ "paid" | i18n }}

View File

@@ -12,7 +12,10 @@ import {
}) })
export class BillingHistoryComponent { export class BillingHistoryComponent {
@Input() @Input()
invoices: BillingInvoiceResponse[]; openInvoices: BillingInvoiceResponse[];
@Input()
paidInvoices: BillingInvoiceResponse[];
@Input() @Input()
transactions: BillingTransactionResponse[]; transactions: BillingTransactionResponse[];

View File

@@ -10,6 +10,7 @@ import {
unauthGuardFn, unauthGuardFn,
} from "@bitwarden/angular/auth/guards"; } from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
import { import {
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,
@@ -72,6 +73,7 @@ import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-land
import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component"; import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component";
import { DomainRulesComponent } from "./settings/domain-rules.component"; import { DomainRulesComponent } from "./settings/domain-rules.component";
import { PreferencesComponent } from "./settings/preferences.component"; import { PreferencesComponent } from "./settings/preferences.component";
import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component";
import { GeneratorComponent } from "./tools/generator.component"; import { GeneratorComponent } from "./tools/generator.component";
import { ReportsModule } from "./tools/reports"; import { ReportsModule } from "./tools/reports";
import { AccessComponent } from "./tools/send/access.component"; import { AccessComponent } from "./tools/send/access.component";
@@ -645,11 +647,10 @@ const routes: Routes = [
titleId: "exportVault", titleId: "exportVault",
} satisfies RouteDataProperties, } satisfies RouteDataProperties,
}, },
{ ...generatorSwap(GeneratorComponent, CredentialGeneratorComponent, {
path: "generator", path: "generator",
component: GeneratorComponent,
data: { titleId: "generator" } satisfies RouteDataProperties, data: { titleId: "generator" } satisfies RouteDataProperties,
}, }),
], ],
}, },
{ {

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

@@ -0,0 +1,5 @@
<app-header></app-header>
<bit-container>
<tools-credential-generator />
</bit-container>

View File

@@ -0,0 +1,14 @@
import { Component } from "@angular/core";
import { GeneratorModule } from "@bitwarden/generator-components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
@Component({
standalone: true,
selector: "credential-generator",
templateUrl: "credential-generator.component.html",
imports: [SharedModule, HeaderModule, GeneratorModule],
})
export class CredentialGeneratorComponent {}

View File

@@ -788,8 +788,8 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
/** /**
* Edit the given cipher * Edit the given cipher or add a new cipher
* @param cipherView - The cipher to be edited * @param cipherView - When set, the cipher to be edited
* @param cloneCipher - `true` when the cipher should be cloned. * @param cloneCipher - `true` when the cipher should be cloned.
* Used in place of the `additionalComponentParameters`, as * Used in place of the `additionalComponentParameters`, as
* the `editCipherIdV2` method has a differing implementation. * the `editCipherIdV2` method has a differing implementation.
@@ -797,7 +797,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* the `AddEditComponent` to edit methods directly. * the `AddEditComponent` to edit methods directly.
*/ */
async editCipher( async editCipher(
cipher: CipherView, cipher: CipherView | null,
cloneCipher: boolean, cloneCipher: boolean,
additionalComponentParameters?: (comp: AddEditComponent) => void, additionalComponentParameters?: (comp: AddEditComponent) => void,
) { ) {
@@ -805,7 +805,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
async editCipherId( async editCipherId(
cipher: CipherView, cipher: CipherView | null,
cloneCipher: boolean, cloneCipher: boolean,
additionalComponentParameters?: (comp: AddEditComponent) => void, additionalComponentParameters?: (comp: AddEditComponent) => void,
) { ) {
@@ -827,7 +827,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const defaultComponentParameters = (comp: AddEditComponent) => { const defaultComponentParameters = (comp: AddEditComponent) => {
comp.organization = this.organization; comp.organization = this.organization;
comp.organizationId = this.organization.id; comp.organizationId = this.organization.id;
comp.cipherId = cipher.id; comp.cipherId = cipher?.id;
comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close(); modal.close();
this.refresh(); this.refresh();
@@ -866,10 +866,10 @@ export class VaultComponent implements OnInit, OnDestroy {
* Edit a cipher using the new AddEditCipherDialogV2 component. * Edit a cipher using the new AddEditCipherDialogV2 component.
* Only to be used behind the ExtensionRefresh feature flag. * 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( const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
cloneCipher ? "clone" : "edit", cloneCipher ? "clone" : "edit",
cipher.id as CipherId, cipher?.id as CipherId | null,
); );
await this.openVaultItemDialog("form", cipherFormConfig, cipher); 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": { "whatTypeOfItem": {
"message": "What type of item is this?" "message": "What type of item is this?"
}, },
@@ -1452,7 +1512,12 @@
"description": "Minimum special characters" "description": "Minimum special characters"
}, },
"ambiguous": { "ambiguous": {
"message": "Avoid ambiguous characters" "message": "Avoid ambiguous characters",
"description": "deprecated. Use avoidAmbiguous instead."
},
"avoidAmbiguous": {
"message": "Avoid ambiguous characters",
"description": "Label for the avoid ambiguous characters checkbox."
}, },
"regeneratePassword": { "regeneratePassword": {
"message": "Regenerate password" "message": "Regenerate password"
@@ -1465,18 +1530,51 @@
}, },
"uppercase": { "uppercase": {
"message": "Uppercase (A-Z)", "message": "Uppercase (A-Z)",
"description": "Include uppercase letters in the password generator." "description": "deprecated. Use uppercaseLabel instead."
}, },
"lowercase": { "lowercase": {
"message": "Lowercase (a-z)", "message": "Lowercase (a-z)",
"description": "Include lowercase letters in the password generator." "description": "deprecated. Use lowercaseLabel instead."
}, },
"numbers": { "numbers": {
"message": "Numbers (0-9)" "message": "Numbers (0-9)",
"description": "deprecated. Use numbersLabel instead."
}, },
"specialCharacters": { "specialCharacters": {
"message": "Special characters (!@#$%^&*)" "message": "Special characters (!@#$%^&*)"
}, },
"uppercaseDescription": {
"message": "Include uppercase characters",
"description": "Tooltip for the password generator uppercase character checkbox"
},
"uppercaseLabel": {
"message": "A-Z",
"description": "Label for the password generator uppercase character checkbox"
},
"lowercaseDescription": {
"message": "Include lowercase characters",
"description": "Full description for the password generator lowercase character checkbox"
},
"lowercaseLabel": {
"message": "a-z",
"description": "Label for the password generator lowercase character checkbox"
},
"numbersDescription": {
"message": "Include numbers",
"description": "Full description for the password generator numbers checkbox"
},
"numbersLabel": {
"message": "0-9",
"description": "Label for the password generator numbers checkbox"
},
"specialCharactersDescription": {
"message": "Include special characters",
"description": "Full description for the password generator special characters checkbox"
},
"specialCharactersLabel": {
"message": "!@#$%^&*",
"description": "Label for the password generator special characters checkbox"
},
"numWords": { "numWords": {
"message": "Number of words" "message": "Number of words"
}, },
@@ -2628,8 +2726,11 @@
"invoices": { "invoices": {
"message": "Invoices" "message": "Invoices"
}, },
"noInvoices": { "noUnpaidInvoices": {
"message": "No invoices." "message": "No unpaid invoices."
},
"noPaidInvoices": {
"message": "No paid invoices."
}, },
"paid": { "paid": {
"message": "Paid", "message": "Paid",
@@ -6235,7 +6336,8 @@
"message": "Account settings" "message": "Account settings"
}, },
"generator": { "generator": {
"message": "Generator" "message": "Generator",
"description": "Short for 'credential generator'."
}, },
"whatWouldYouLikeToGenerate": { "whatWouldYouLikeToGenerate": {
"message": "What would you like to generate?" "message": "What would you like to generate?"

View File

@@ -5,8 +5,7 @@
'tw-pt-0': decreaseTopPadding, 'tw-pt-0': decreaseTopPadding,
'tw-pt-8': !decreaseTopPadding, 'tw-pt-8': !decreaseTopPadding,
'tw-min-h-screen': clientType === 'web', 'tw-min-h-screen': clientType === 'web',
'tw-min-h-[calc(100vh-72px)]': clientType === 'browser', 'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
'tw-min-h-[calc(100vh-54px)]': clientType === 'desktop',
}" }"
> >
<a *ngIf="!hideLogo" [routerLink]="['/']" class="tw-w-[128px] [&>*]:tw-align-top"> <a *ngIf="!hideLogo" [routerLink]="['/']" class="tw-w-[128px] [&>*]:tw-align-top">
@@ -33,7 +32,7 @@
</div> </div>
<div <div
class="tw-mb-auto tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]" class="tw-grow tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }" [ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }"
> >
<div <div
@@ -45,13 +44,15 @@
</div> </div>
<footer *ngIf="!hideFooter" class="tw-text-center"> <footer *ngIf="!hideFooter" class="tw-text-center">
<div *ngIf="showReadonlyHostname">{{ "accessing" | i18n }} {{ hostname }}</div> <div *ngIf="showReadonlyHostname" bitTypography="body2">
{{ "accessing" | i18n }} {{ hostname }}
</div>
<ng-container *ngIf="!showReadonlyHostname"> <ng-container *ngIf="!showReadonlyHostname">
<ng-content select="[slot=environment-selector]"></ng-content> <ng-content select="[slot=environment-selector]"></ng-content>
</ng-container> </ng-container>
<ng-container *ngIf="!hideYearAndVersion"> <ng-container *ngIf="!hideYearAndVersion">
<div>&copy; {{ year }} Bitwarden Inc.</div> <div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div>{{ version }}</div> <div bitTypography="body2">{{ version }}</div>
</ng-container> </ng-container>
</footer> </footer>
</main> </main>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core"; import { Component, HostBinding, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -19,6 +19,12 @@ import { BitwardenLogo, VaultIcon } from "../icons";
imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule], imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule],
}) })
export class AnonLayoutComponent implements OnInit, OnChanges { export class AnonLayoutComponent implements OnInit, OnChanges {
@HostBinding("class")
get classList() {
// AnonLayout should take up full height of parent container for proper footer placement.
return ["tw-h-full"];
}
@Input() title: string; @Input() title: string;
@Input() subtitle: string; @Input() subtitle: string;
@Input() icon: Icon; @Input() icon: Icon;

View File

@@ -1,7 +1,10 @@
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request"; import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request";
import { OrganizationDomainSsoDetailsResponse } from "./responses/organization-domain-sso-details.response"; import { OrganizationDomainSsoDetailsResponse } from "./responses/organization-domain-sso-details.response";
import { OrganizationDomainResponse } from "./responses/organization-domain.response"; import { OrganizationDomainResponse } from "./responses/organization-domain.response";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "./responses/verified-organization-domain-sso-details.response";
export abstract class OrgDomainApiServiceAbstraction { export abstract class OrgDomainApiServiceAbstraction {
getAllByOrgId: (orgId: string) => Promise<Array<OrganizationDomainResponse>>; getAllByOrgId: (orgId: string) => Promise<Array<OrganizationDomainResponse>>;
@@ -16,4 +19,7 @@ export abstract class OrgDomainApiServiceAbstraction {
verify: (orgId: string, orgDomainId: string) => Promise<OrganizationDomainResponse>; verify: (orgId: string, orgDomainId: string) => Promise<OrganizationDomainResponse>;
delete: (orgId: string, orgDomainId: string) => Promise<any>; delete: (orgId: string, orgDomainId: string) => Promise<any>;
getClaimedOrgDomainByEmail: (email: string) => Promise<OrganizationDomainSsoDetailsResponse>; getClaimedOrgDomainByEmail: (email: string) => Promise<OrganizationDomainSsoDetailsResponse>;
getVerifiedOrgDomainsByEmail: (
email: string,
) => Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>>;
} }

View File

@@ -0,0 +1,15 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class VerifiedOrganizationDomainSsoDetailsResponse extends BaseResponse {
organizationName: string;
organizationIdentifier: string;
domainName: string;
constructor(response: any) {
super(response);
this.organizationName = this.getResponseProperty("organizationName");
this.organizationIdentifier = this.getResponseProperty("organizationIdentifier");
this.domainName = this.getResponseProperty("domainName");
}
}

View File

@@ -1,6 +1,9 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ApiService } from "../../../abstractions/api.service"; import { ApiService } from "../../../abstractions/api.service";
import { I18nService } from "../../../platform/abstractions/i18n.service"; import { I18nService } from "../../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
@@ -81,6 +84,19 @@ const mockedOrganizationDomainSsoDetailsResponse = new OrganizationDomainSsoDeta
mockedOrganizationDomainSsoDetailsServerResponse, mockedOrganizationDomainSsoDetailsServerResponse,
); );
const mockedVerifiedOrganizationDomain = {
organizationIdentifier: "fake-org-identifier",
organizationName: "fake-org",
domainName: "fake-domain-name",
};
const mockedVerifiedOrganizationDomainSsoResponse =
new VerifiedOrganizationDomainSsoDetailsResponse(mockedVerifiedOrganizationDomain);
const mockedVerifiedOrganizationDomainSsoDetailsListResponse = {
data: [mockedVerifiedOrganizationDomain],
} as ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>;
describe("Org Domain API Service", () => { describe("Org Domain API Service", () => {
let orgDomainApiService: OrgDomainApiService; let orgDomainApiService: OrgDomainApiService;
@@ -229,4 +245,21 @@ describe("Org Domain API Service", () => {
expect(result).toEqual(mockedOrganizationDomainSsoDetailsResponse); expect(result).toEqual(mockedOrganizationDomainSsoDetailsResponse);
}); });
it("getVerifiedOrgDomainsByEmail should call ApiService.send with correct parameters and return response", async () => {
const email = "test@example.com";
apiService.send.mockResolvedValue(mockedVerifiedOrganizationDomainSsoDetailsListResponse);
const result = await orgDomainApiService.getVerifiedOrgDomainsByEmail(email);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
"/organizations/domain/sso/verified",
new OrganizationDomainSsoDetailsRequest(email),
false, //anonymous
true,
);
expect(result.data).toContainEqual(mockedVerifiedOrganizationDomainSsoResponse);
});
}); });

View File

@@ -4,6 +4,7 @@ import { OrgDomainApiServiceAbstraction } from "../../abstractions/organization-
import { OrgDomainInternalServiceAbstraction } from "../../abstractions/organization-domain/org-domain.service.abstraction"; import { OrgDomainInternalServiceAbstraction } from "../../abstractions/organization-domain/org-domain.service.abstraction";
import { OrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/organization-domain-sso-details.response"; import { OrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/organization-domain-sso-details.response";
import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response"; import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { OrganizationDomainSsoDetailsRequest } from "./requests/organization-domain-sso-details.request"; import { OrganizationDomainSsoDetailsRequest } from "./requests/organization-domain-sso-details.request";
import { OrganizationDomainRequest } from "./requests/organization-domain.request"; import { OrganizationDomainRequest } from "./requests/organization-domain.request";
@@ -109,4 +110,18 @@ export class OrgDomainApiService implements OrgDomainApiServiceAbstraction {
return response; return response;
} }
async getVerifiedOrgDomainsByEmail(
email: string,
): Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>> {
const result = await this.apiService.send(
"POST",
`/organizations/domain/sso/verified`,
new OrganizationDomainSsoDetailsRequest(email),
false, // anonymous
true,
);
return new ListResponse(result, VerifiedOrganizationDomainSsoDetailsResponse);
}
} }

View File

@@ -4,9 +4,6 @@ import {
} from "@bitwarden/common/billing/models/response/billing.response"; } from "@bitwarden/common/billing/models/response/billing.response";
export class AccountBillingApiServiceAbstraction { export class AccountBillingApiServiceAbstraction {
getBillingInvoices: (id: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>; getBillingInvoices: (status?: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>;
getBillingTransactions: ( getBillingTransactions: (startAfter?: string) => Promise<BillingTransactionResponse[]>;
id: string,
startAfter?: string,
) => Promise<BillingTransactionResponse[]>;
} }

View File

@@ -4,7 +4,12 @@ import {
} from "@bitwarden/common/billing/models/response/billing.response"; } from "@bitwarden/common/billing/models/response/billing.response";
export class OrganizationBillingApiServiceAbstraction { export class OrganizationBillingApiServiceAbstraction {
getBillingInvoices: (id: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>; getBillingInvoices: (
id: string,
status?: string,
startAfter?: string,
) => Promise<BillingInvoiceResponse[]>;
getBillingTransactions: ( getBillingTransactions: (
id: string, id: string,
startAfter?: string, startAfter?: string,

View File

@@ -8,11 +8,25 @@ import {
export class AccountBillingApiService implements AccountBillingApiServiceAbstraction { export class AccountBillingApiService implements AccountBillingApiServiceAbstraction {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}
async getBillingInvoices(startAfter?: string): Promise<BillingInvoiceResponse[]> { async getBillingInvoices(
const queryParams = startAfter ? `?startAfter=${startAfter}` : ""; status?: string,
startAfter?: string,
): Promise<BillingInvoiceResponse[]> {
const params = new URLSearchParams();
if (status) {
params.append("status", status);
}
if (startAfter) {
params.append("startAfter", startAfter);
}
const queryString = `?${params.toString()}`;
const r = await this.apiService.send( const r = await this.apiService.send(
"GET", "GET",
`/accounts/billing/invoices${queryParams}`, `/accounts/billing/invoices${queryString}`,
null, null,
true, true,
true, true,

View File

@@ -8,11 +8,26 @@ import {
export class OrganizationBillingApiService implements OrganizationBillingApiServiceAbstraction { export class OrganizationBillingApiService implements OrganizationBillingApiServiceAbstraction {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}
async getBillingInvoices(id: string, startAfter?: string): Promise<BillingInvoiceResponse[]> { async getBillingInvoices(
const queryParams = startAfter ? `?startAfter=${startAfter}` : ""; id: string,
status?: string,
startAfter?: string,
): Promise<BillingInvoiceResponse[]> {
const params = new URLSearchParams();
if (status) {
params.append("status", status);
}
if (startAfter) {
params.append("startAfter", startAfter);
}
const queryString = `?${params.toString()}`;
const r = await this.apiService.send( const r = await this.apiService.send(
"GET", "GET",
`/organizations/${id}/billing/invoices${queryParams}`, `/organizations/${id}/billing/invoices${queryString}`,
null, null,
true, true,
true, true,

View File

@@ -31,8 +31,10 @@ export enum FeatureFlag {
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api", AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption", CipherKeyEncryption = "cipher-key-encryption",
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader", PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api", Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api",
AccessIntelligence = "pm-13227-access-intelligence",
} }
export type AllowedFeatureFlagTypes = boolean | number | string; export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -74,8 +76,10 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE, [FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE, [FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
[FeatureFlag.Pm3478RefactorOrganizationUserApi]: FALSE, [FeatureFlag.Pm3478RefactorOrganizationUserApi]: FALSE,
[FeatureFlag.AccessIntelligence]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>; } satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -1,9 +1,9 @@
@import "./reset.css"; @import "./reset.css";
/** /**
Note that the value of the *-600 colors is currently equivalent to the value Note that the value of the *-600 colors is currently equivalent to the value
of the *-500 variant of that color. This is a temporary change to make BW-42 of the *-500 variant of that color. This is a temporary change to make BW-42
updates easier. updates easier.
TODO remove comment when the color palette portion of BW-42 is completed. TODO remove comment when the color palette portion of BW-42 is completed.
*/ */
@@ -196,7 +196,7 @@
@import "./toast/toast.tokens.css"; @import "./toast/toast.tokens.css";
@import "./toast/toastr.css"; @import "./toast/toastr.css";
/** /**
* tw-break-words does not work with table cells: * tw-break-words does not work with table cells:
* https://github.com/tailwindlabs/tailwindcss/issues/835 * https://github.com/tailwindlabs/tailwindcss/issues/835
*/ */
@@ -204,7 +204,7 @@ td.tw-break-words {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/** /**
* tw-list-none hides summary arrow in Firefox & Chrome but not Safari: * tw-list-none hides summary arrow in Firefox & Chrome but not Safari:
* https://github.com/tailwindlabs/tailwindcss/issues/924#issuecomment-915509785 * https://github.com/tailwindlabs/tailwindcss/issues/924#issuecomment-915509785
*/ */
@@ -213,7 +213,7 @@ summary.tw-list-none::-webkit-details-marker {
display: none; display: none;
} }
/** /**
* Arbitrary values can't be used with `text-align`: * Arbitrary values can't be used with `text-align`:
* https://github.com/tailwindlabs/tailwindcss/issues/802#issuecomment-849013311 * https://github.com/tailwindlabs/tailwindcss/issues/802#issuecomment-849013311
*/ */
@@ -222,10 +222,11 @@ summary.tw-list-none::-webkit-details-marker {
} }
/** /**
* Bootstrap uses z-index: 1050 for modals, dialogs should appear above them. * Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them.
* Remove once bootstrap is removed from our codebase. * When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content.
* CL-XYZ * CL-483
*/ */
.cdk-drag-preview,
.cdk-overlay-container, .cdk-overlay-container,
.cdk-global-overlay-wrapper, .cdk-global-overlay-wrapper,
.cdk-overlay-connected-position-bounding-box, .cdk-overlay-connected-position-bounding-box,

View File

@@ -0,0 +1,40 @@
<!-- FIXME: make this one or more storybooks -->
## Using generator components
The components within this module require the following import.
```ts
import { GeneratorModule } from "@bitwarden/generator-components";
```
The credential generator provides access to all generator features.
```html
<!-- Bound to active user -->
<tools-credential-generator />
<!-- Bound to a specific user -->
<tools-credential-generator [user-id]="userId" />
<!-- receive updates when a credential is generated.
`$event` is a `GeneratedCredential`.
-->
<tools-credential-generator (onGenerated)="eventHandler($event)" />
```
Specialized components are provided for username and password generation. These
components support the same properties as the credential generator.
```html
<tools-password-generator [user-id]="userId" (onGenerated)="eventHandler($event)" />
<tools-username-generator [user-id]="userId" (onGenerated)="eventHandler($event)" />
```
The emission behavior of `onGenerated` varies according to credential type. When
a credential supports immediate execution, the component automatically generates
a value and emits from `onGenerated`. An additional emission occurs each time the
user changes a setting. Users may also request a regeneration.
When a credential does not support immediate execution, then `onGenerated` fires
only when the user clicks the "generate" button.

View File

@@ -10,15 +10,12 @@ import {
Generators, Generators,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util"; import { completeOnAccountSwitch } from "./util";
/** Options group for catchall emails */ /** Options group for catchall emails */
@Component({ @Component({
standalone: true,
selector: "tools-catchall-settings", selector: "tools-catchall-settings",
templateUrl: "catchall-settings.component.html", templateUrl: "catchall-settings.component.html",
imports: [DependenciesModule],
}) })
export class CatchallSettingsComponent implements OnInit, OnDestroy { export class CatchallSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component /** Instantiates the component

View File

@@ -0,0 +1,77 @@
<!-- FIXME: root$ should be powered using a reactive form -->
<bit-toggle-group
fullWidth
class="tw-mb-4"
[selected]="(root$ | async).nav"
(selectedChange)="onRootChanged($event)"
attr.aria-label="{{ 'type' | i18n }}"
>
<bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value">
{{ option.label }}
</bit-toggle>
</bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow tw-flex tw-items-center">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
<button
type="button"
bitIconButton="bwi-clone"
buttonType="main"
showToast
[appCopyClick]="value$ | async"
>
{{ "copyPassword" | i18n }}
</button>
</div>
</bit-card>
<tools-password-settings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'password'"
[userId]="userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-passphrase-settings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'passphrase'"
[userId]="userId$ | async"
(onUpdated)="generate$.next()"
/>
<bit-section *ngIf="(category$ | async) !== 'password'">
<bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<div class="tw-mb-4">
<bit-card>
<form class="box" [formGroup]="username" class="tw-container">
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<bit-select [items]="usernameOptions$ | async" formControlName="nav"> </bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async
}}</bit-hint>
</bit-form-field>
</form>
<tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-username-settings
*ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="userId$ | async"
(onUpdated)="generate$.next()"
/>
</bit-card>
</div>
</bit-section>

View File

@@ -0,0 +1,293 @@
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import {
BehaviorSubject,
concat,
distinctUntilChanged,
filter,
map,
of,
ReplaySubject,
Subject,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { Option } from "@bitwarden/components/src/select/option";
import {
CredentialAlgorithm,
CredentialCategory,
CredentialGeneratorInfo,
CredentialGeneratorService,
GeneratedCredential,
Generators,
isEmailAlgorithm,
isPasswordAlgorithm,
isUsernameAlgorithm,
PasswordAlgorithm,
} from "@bitwarden/generator-core";
/** root category that drills into username and email categories */
const IDENTIFIER = "identifier";
/** options available for the top-level navigation */
type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER;
@Component({
selector: "tools-credential-generator",
templateUrl: "credential-generator.component.html",
})
export class CredentialGeneratorComponent implements OnInit, OnDestroy {
constructor(
private generatorService: CredentialGeneratorService,
private i18nService: I18nService,
private accountService: AccountService,
private zone: NgZone,
private formBuilder: FormBuilder,
) {}
/** Binds the component to a specific user's settings. When this input is not provided,
* the form binds to the active user
*/
@Input()
userId: UserId | null;
/** Emits credentials created from a generation request. */
@Output()
readonly onGenerated = new EventEmitter<GeneratedCredential>();
protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({
nav: null,
});
protected onRootChanged(nav: RootNavValue) {
// prevent subscription cycle
if (this.root$.value.nav !== nav) {
this.zone.run(() => {
this.root$.next({ nav });
});
}
}
protected username = this.formBuilder.group({
nav: [null as CredentialAlgorithm],
});
async ngOnInit() {
if (this.userId) {
this.userId$.next(this.userId);
} else {
this.accountService.activeAccount$
.pipe(
map((acct) => acct.id),
distinctUntilChanged(),
takeUntil(this.destroyed),
)
.subscribe(this.userId$);
}
this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ })
.pipe(
map((algorithms) => this.toOptions(algorithms)),
takeUntil(this.destroyed),
)
.subscribe(this.usernameOptions$);
this.generatorService
.algorithms$("password", { userId$: this.userId$ })
.pipe(
map((algorithms) => {
const options = this.toOptions(algorithms) as Option<RootNavValue>[];
options.push({ value: IDENTIFIER, label: this.i18nService.t("username") });
return options;
}),
takeUntil(this.destroyed),
)
.subscribe(this.rootOptions$);
this.algorithm$
.pipe(
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)),
takeUntil(this.destroyed),
)
.subscribe((hint) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.credentialTypeHint$.next(hint);
});
});
this.algorithm$
.pipe(
map((a) => a.category),
distinctUntilChanged(),
takeUntil(this.destroyed),
)
.subscribe((category) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.category$.next(category);
});
});
// wire up the generator
this.algorithm$
.pipe(
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
takeUntil(this.destroyed),
)
.subscribe((generated) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.onGenerated.next(generated);
this.value$.next(generated.credential);
});
});
// assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.root$
.pipe(
filter(({ nav }) => !!nav),
switchMap((root) => {
if (root.nav === IDENTIFIER) {
return concat(of(this.username.value), this.username.valueChanges);
} else {
return of(root as { nav: PasswordAlgorithm });
}
}),
filter(({ nav }) => !!nav),
withLatestFrom(preferences),
takeUntil(this.destroyed),
)
.subscribe(([{ nav: algorithm }, preference]) => {
function setPreference(category: CredentialCategory) {
const p = preference[category];
p.algorithm = algorithm;
p.updated = new Date();
}
// `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference`
if (isEmailAlgorithm(algorithm)) {
setPreference("email");
} else if (isUsernameAlgorithm(algorithm)) {
setPreference("username");
} else if (isPasswordAlgorithm(algorithm)) {
setPreference("password");
} else {
return;
}
preferences.next(preference);
});
// populate the form with the user's preferences to kick off interactivity
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => {
// the last preference set by the user "wins"
const userNav = email.updated > username.updated ? email : username;
const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm;
const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm;
// update navigation; break subscription loop
this.onRootChanged(rootNav);
this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false });
// load algorithm metadata
const algorithm = this.generatorService.algorithm(credentialType);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
.pipe(
distinctUntilChanged((prev, next) => prev.id === next.id),
filter((a) => !a.onlyOnRequest),
takeUntil(this.destroyed),
)
.subscribe(() => this.generate$.next());
}
private typeToGenerator$(type: CredentialAlgorithm) {
const dependencies = {
on$: this.generate$,
userId$: this.userId$,
};
switch (type) {
case "catchall":
return this.generatorService.generate$(Generators.catchall, dependencies);
case "subaddress":
return this.generatorService.generate$(Generators.subaddress, dependencies);
case "username":
return this.generatorService.generate$(Generators.username, dependencies);
case "password":
return this.generatorService.generate$(Generators.password, dependencies);
case "passphrase":
return this.generatorService.generate$(Generators.passphrase, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
}
}
/** Lists the credential types of the username algorithm box. */
protected usernameOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
/** Lists the top-level credential types supported by the component. */
protected rootOptions$ = new BehaviorSubject<Option<RootNavValue>[]>([]);
/** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
/** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1);
/** tracks the currently selected credential category */
protected category$ = new ReplaySubject<string>(1);
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>();
private toOptions(algorithms: CredentialGeneratorInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id,
label: this.i18nService.t(algorithm.nameKey),
}));
return options;
}
private readonly destroyed = new Subject<void>();
ngOnDestroy() {
this.destroyed.complete();
// finalize subjects
this.generate$.complete();
this.value$.complete();
// finalize component bindings
this.onGenerated.complete();
}
}

View File

@@ -10,8 +10,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
import { import {
CardComponent, CardComponent,
CheckboxModule,
ColorPasswordModule, ColorPasswordModule,
CheckboxModule,
FormFieldModule, FormFieldModule,
IconButtonModule, IconButtonModule,
InputModule, InputModule,
@@ -27,16 +27,24 @@ import {
Randomizer, Randomizer,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { CredentialGeneratorComponent } from "./credential-generator.component";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordGeneratorComponent } from "./password-generator.component";
import { PasswordSettingsComponent } from "./password-settings.component";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameGeneratorComponent } from "./username-generator.component";
import { UsernameSettingsComponent } from "./username-settings.component";
const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer"); const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
/** Shared module containing generator component dependencies */ /** Shared module containing generator component dependencies */
@NgModule({ @NgModule({
imports: [CardComponent, SectionComponent, SectionHeaderComponent], imports: [
exports: [
CardComponent, CardComponent,
ColorPasswordModule,
CheckboxModule, CheckboxModule,
CommonModule, CommonModule,
ColorPasswordModule,
FormFieldModule, FormFieldModule,
IconButtonModule, IconButtonModule,
InputModule, InputModule,
@@ -60,8 +68,18 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
deps: [RANDOMIZER, StateProvider, PolicyService], deps: [RANDOMIZER, StateProvider, PolicyService],
}), }),
], ],
declarations: [], declarations: [
CatchallSettingsComponent,
CredentialGeneratorComponent,
SubaddressSettingsComponent,
UsernameSettingsComponent,
PasswordGeneratorComponent,
PasswordSettingsComponent,
PassphraseSettingsComponent,
UsernameGeneratorComponent,
],
exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
}) })
export class DependenciesModule { export class GeneratorModule {
constructor() {} constructor() {}
} }

View File

@@ -1,9 +1,3 @@
export { CatchallSettingsComponent } from "./catchall-settings.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { PassphraseSettingsComponent } from "./passphrase-settings.component"; export { GeneratorModule } from "./generator.module";
export { PasswordSettingsComponent } from "./password-settings.component";
export { PasswordGeneratorComponent } from "./password-generator.component";
export { SubaddressSettingsComponent } from "./subaddress-settings.component";
export { UsernameGeneratorComponent } from "./username-generator.component";
export { UsernameSettingsComponent } from "./username-settings.component";

View File

@@ -10,7 +10,6 @@ import {
PassphraseGenerationOptions, PassphraseGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch, toValidators } from "./util"; import { completeOnAccountSwitch, toValidators } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
@@ -22,10 +21,8 @@ const Controls = Object.freeze({
/** Options group for passphrases */ /** Options group for passphrases */
@Component({ @Component({
standalone: true,
selector: "tools-passphrase-settings", selector: "tools-passphrase-settings",
templateUrl: "passphrase-settings.component.html", templateUrl: "passphrase-settings.component.html",
imports: [DependenciesModule],
}) })
export class PassphraseSettingsComponent implements OnInit, OnDestroy { export class PassphraseSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component /** Instantiates the component

View File

@@ -5,11 +5,8 @@
(selectedChange)="onCredentialTypeChanged($event)" (selectedChange)="onCredentialTypeChanged($event)"
attr.aria-label="{{ 'type' | i18n }}" attr.aria-label="{{ 'type' | i18n }}"
> >
<bit-toggle value="password"> <bit-toggle *ngFor="let option of passwordOptions$ | async" [value]="option.value">
{{ "password" | i18n }} {{ option.label }}
</bit-toggle>
<bit-toggle value="passphrase">
{{ "passphrase" | i18n }}
</bit-toggle> </bit-toggle>
</bit-toggle-group> </bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4"> <bit-card class="tw-flex tw-justify-between tw-mb-4">
@@ -24,6 +21,7 @@
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
buttonType="main" buttonType="main"
showToast
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
> >
{{ "copyPassword" | i18n }} {{ "copyPassword" | i18n }}
@@ -32,13 +30,13 @@
</bit-card> </bit-card>
<tools-password-settings <tools-password-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'password'" *ngIf="(algorithm$ | async)?.id === 'password'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<tools-passphrase-settings <tools-passphrase-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'passphrase'" *ngIf="(algorithm$ | async)?.id === 'passphrase'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />

View File

@@ -1,29 +1,39 @@
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; import {
BehaviorSubject,
distinctUntilChanged,
filter,
map,
ReplaySubject,
Subject,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { Option } from "@bitwarden/components/src/select/option";
import { import {
CredentialGeneratorService, CredentialGeneratorService,
Generators, Generators,
PasswordAlgorithm, PasswordAlgorithm,
GeneratedCredential, GeneratedCredential,
CredentialGeneratorInfo,
CredentialAlgorithm,
isPasswordAlgorithm,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordSettingsComponent } from "./password-settings.component";
/** Options group for passwords */ /** Options group for passwords */
@Component({ @Component({
standalone: true,
selector: "tools-password-generator", selector: "tools-password-generator",
templateUrl: "password-generator.component.html", templateUrl: "password-generator.component.html",
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
}) })
export class PasswordGeneratorComponent implements OnInit, OnDestroy { export class PasswordGeneratorComponent implements OnInit, OnDestroy {
constructor( constructor(
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private i18nService: I18nService,
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
) {} ) {}
@@ -36,7 +46,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
userId: UserId | null; userId: UserId | null;
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>("password"); protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null);
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
@@ -51,9 +61,11 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
* @param type the new credential type * @param type the new credential type
*/ */
protected onCredentialTypeChanged(type: PasswordAlgorithm) { protected onCredentialTypeChanged(type: PasswordAlgorithm) {
// break subscription cycle
if (this.credentialType$.value !== type) { if (this.credentialType$.value !== type) {
this.credentialType$.next(type); this.zone.run(() => {
this.generate$.next(); this.credentialType$.next(type);
});
} }
} }
@@ -74,9 +86,18 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
.subscribe(this.userId$); .subscribe(this.userId$);
} }
this.credentialType$ this.generatorService
.algorithms$("password", { userId$: this.userId$ })
.pipe( .pipe(
switchMap((type) => this.typeToGenerator$(type)), map((algorithms) => this.toOptions(algorithms)),
takeUntil(this.destroyed),
)
.subscribe(this.passwordOptions$);
// wire up the generator
this.algorithm$
.pipe(
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((generated) => { .subscribe((generated) => {
@@ -87,9 +108,52 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
this.value$.next(generated.credential); this.value$.next(generated.credential);
}); });
}); });
// assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.credentialType$
.pipe(
filter((type) => !!type),
withLatestFrom(preferences),
takeUntil(this.destroyed),
)
.subscribe(([algorithm, preference]) => {
if (isPasswordAlgorithm(algorithm)) {
preference.password.algorithm = algorithm;
preference.password.updated = new Date();
} else {
return;
}
preferences.next(preference);
});
// populate the form with the user's preferences to kick off interactivity
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => {
// update navigation
this.onCredentialTypeChanged(password.algorithm);
// load algorithm metadata
const algorithm = this.generatorService.algorithm(password.algorithm);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
.pipe(
distinctUntilChanged((prev, next) => prev.id === next.id),
filter((a) => !a.onlyOnRequest),
takeUntil(this.destroyed),
)
.subscribe(() => this.generate$.next());
} }
private typeToGenerator$(type: PasswordAlgorithm) { private typeToGenerator$(type: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
userId$: this.userId$, userId$: this.userId$,
@@ -106,6 +170,21 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
} }
} }
/** Lists the credential types supported by the component. */
protected passwordOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
/** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
private toOptions(algorithms: CredentialGeneratorInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id,
label: this.i18nService.t(algorithm.nameKey),
}));
return options;
}
private readonly destroyed = new Subject<void>(); private readonly destroyed = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
// tear down subscriptions // tear down subscriptions

View File

@@ -10,7 +10,6 @@ import {
PasswordGenerationOptions, PasswordGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch, toValidators } from "./util"; import { completeOnAccountSwitch, toValidators } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
@@ -26,10 +25,8 @@ const Controls = Object.freeze({
/** Options group for passwords */ /** Options group for passwords */
@Component({ @Component({
standalone: true,
selector: "tools-password-settings", selector: "tools-password-settings",
templateUrl: "password-settings.component.html", templateUrl: "password-settings.component.html",
imports: [DependenciesModule],
}) })
export class PasswordSettingsComponent implements OnInit, OnDestroy { export class PasswordSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component /** Instantiates the component

View File

@@ -10,15 +10,12 @@ import {
SubaddressGenerationOptions, SubaddressGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util"; import { completeOnAccountSwitch } from "./util";
/** Options group for plus-addressed emails */ /** Options group for plus-addressed emails */
@Component({ @Component({
standalone: true,
selector: "tools-subaddress-settings", selector: "tools-subaddress-settings",
templateUrl: "subaddress-settings.component.html", templateUrl: "subaddress-settings.component.html",
imports: [DependenciesModule],
}) })
export class SubaddressSettingsComponent implements OnInit, OnDestroy { export class SubaddressSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component /** Instantiates the component

View File

@@ -10,6 +10,7 @@
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
buttonType="main" buttonType="main"
showToast
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
> >
{{ "copyPassword" | i18n }} {{ "copyPassword" | i18n }}

View File

@@ -26,23 +26,10 @@ import {
isUsernameAlgorithm, isUsernameAlgorithm,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { DependenciesModule } from "./dependencies";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameSettingsComponent } from "./username-settings.component";
import { completeOnAccountSwitch } from "./util";
/** Component that generates usernames and emails */ /** Component that generates usernames and emails */
@Component({ @Component({
standalone: true,
selector: "tools-username-generator", selector: "tools-username-generator",
templateUrl: "username-generator.component.html", templateUrl: "username-generator.component.html",
imports: [
DependenciesModule,
CatchallSettingsComponent,
SubaddressSettingsComponent,
UsernameSettingsComponent,
],
}) })
export class UsernameGeneratorComponent implements OnInit, OnDestroy { export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Instantiates the username generator /** Instantiates the username generator
@@ -72,14 +59,20 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Tracks the selected generation algorithm */ /** Tracks the selected generation algorithm */
protected credential = this.formBuilder.group({ protected credential = this.formBuilder.group({
type: ["username" as CredentialAlgorithm], type: [null as CredentialAlgorithm],
}); });
async ngOnInit() { async ngOnInit() {
if (this.userId) { if (this.userId) {
this.userId$.next(this.userId); this.userId$.next(this.userId);
} else { } else {
this.singleUserId$().pipe(takeUntil(this.destroyed)).subscribe(this.userId$); this.accountService.activeAccount$
.pipe(
map((acct) => acct.id),
distinctUntilChanged(),
takeUntil(this.destroyed),
)
.subscribe(this.userId$);
} }
this.generatorService this.generatorService
@@ -121,7 +114,11 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
// assume the last-visible generator algorithm is the user's preferred one // assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.credential.valueChanges this.credential.valueChanges
.pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) .pipe(
filter(({ type }) => !!type),
withLatestFrom(preferences),
takeUntil(this.destroyed),
)
.subscribe(([{ type }, preference]) => { .subscribe(([{ type }, preference]) => {
if (isEmailAlgorithm(type)) { if (isEmailAlgorithm(type)) {
preference.email.algorithm = type; preference.email.algorithm = type;
@@ -202,19 +199,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>(); protected readonly generate$ = new Subject<void>();
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed),
);
}
private toOptions(algorithms: CredentialGeneratorInfo[]) { private toOptions(algorithms: CredentialGeneratorInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({ const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id, value: algorithm.id,

View File

@@ -10,15 +10,12 @@ import {
Generators, Generators,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util"; import { completeOnAccountSwitch } from "./util";
/** Options group for usernames */ /** Options group for usernames */
@Component({ @Component({
standalone: true,
selector: "tools-username-settings", selector: "tools-username-settings",
templateUrl: "username-settings.component.html", templateUrl: "username-settings.component.html",
imports: [DependenciesModule],
}) })
export class UsernameSettingsComponent implements OnInit, OnDestroy { export class UsernameSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component /** Instantiates the component

View File

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

View File

@@ -15,7 +15,9 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -175,6 +177,12 @@ export default {
collect: () => Promise.resolve(), collect: () => Promise.resolve(),
}, },
}, },
{
provide: PlatformUtilsService,
useValue: {
getClientType: () => ClientType.Browser,
},
},
], ],
}), }),
componentWrapperDecorator( componentWrapperDecorator(

View File

@@ -7,6 +7,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
@@ -23,10 +24,12 @@ describe("AutofillOptionsComponent", () => {
let liveAnnouncer: MockProxy<LiveAnnouncer>; let liveAnnouncer: MockProxy<LiveAnnouncer>;
let domainSettingsService: MockProxy<DomainSettingsService>; let domainSettingsService: MockProxy<DomainSettingsService>;
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>; let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
beforeEach(async () => { beforeEach(async () => {
cipherFormContainer = mock<CipherFormContainer>(); cipherFormContainer = mock<CipherFormContainer>();
liveAnnouncer = mock<LiveAnnouncer>(); liveAnnouncer = mock<LiveAnnouncer>();
platformUtilsService = mock<PlatformUtilsService>();
domainSettingsService = mock<DomainSettingsService>(); domainSettingsService = mock<DomainSettingsService>();
domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null); domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null);
@@ -45,6 +48,7 @@ describe("AutofillOptionsComponent", () => {
{ provide: LiveAnnouncer, useValue: liveAnnouncer }, { provide: LiveAnnouncer, useValue: liveAnnouncer },
{ provide: DomainSettingsService, useValue: domainSettingsService }, { provide: DomainSettingsService, useValue: domainSettingsService },
{ provide: AutofillSettingsServiceAbstraction, useValue: autofillSettingsService }, { provide: AutofillSettingsServiceAbstraction, useValue: autofillSettingsService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
], ],
}).compileComponents(); }).compileComponents();

View File

@@ -3,13 +3,15 @@ import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core"; import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { Subject, switchMap, take } from "rxjs"; import { filter, Subject, switchMap, take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service"; import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { import {
@@ -69,7 +71,10 @@ export class AutofillOptionsComponent implements OnInit {
return this.autofillOptionsForm.controls.uris.controls; return this.autofillOptionsForm.controls.uris.controls;
} }
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$; protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$.pipe(
// The default match detection should only be shown when used on the browser
filter(() => this.platformUtilsService.getClientType() == ClientType.Browser),
);
protected autofillOnPageLoadEnabled$ = this.autofillSettingsService.autofillOnPageLoad$; protected autofillOnPageLoadEnabled$ = this.autofillSettingsService.autofillOnPageLoad$;
protected autofillOptions: { label: string; value: boolean | null }[] = [ protected autofillOptions: { label: string; value: boolean | null }[] = [
@@ -90,6 +95,7 @@ export class AutofillOptionsComponent implements OnInit {
private liveAnnouncer: LiveAnnouncer, private liveAnnouncer: LiveAnnouncer,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) { ) {
this.cipherFormContainer.registerChildForm("autoFillOptions", this.autofillOptionsForm); this.cipherFormContainer.registerChildForm("autoFillOptions", this.autofillOptionsForm);

View File

@@ -51,6 +51,13 @@ describe("UriOptionComponent", () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it("should not update the default uri match strategy label when it is null", () => {
component.defaultMatchDetection = null;
fixture.detectChanges();
expect(component["uriMatchOptions"][0].label).toBe("default");
});
it("should update the default uri match strategy label", () => { it("should update the default uri match strategy label", () => {
component.defaultMatchDetection = UriMatchStrategy.Exact; component.defaultMatchDetection = UriMatchStrategy.Exact;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -83,6 +83,11 @@ export class UriOptionComponent implements ControlValueAccessor {
*/ */
@Input({ required: true }) @Input({ required: true })
set defaultMatchDetection(value: UriMatchStrategySetting) { set defaultMatchDetection(value: UriMatchStrategySetting) {
// The default selection has a value of `null` avoid showing "Default (Default)"
if (!value) {
return;
}
this.uriMatchOptions[0].label = this.i18nService.t( this.uriMatchOptions[0].label = this.i18nService.t(
"defaultLabel", "defaultLabel",
this.uriMatchOptions.find((o) => o.value === value)?.label, this.uriMatchOptions.find((o) => o.value === value)?.label,

View File

@@ -3,10 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { import { GeneratorModule } from "@bitwarden/generator-components";
PasswordGeneratorComponent,
UsernameGeneratorComponent,
} from "@bitwarden/generator-components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { CipherFormGeneratorComponent } from "@bitwarden/vault";
@Component({ @Component({
@@ -37,7 +34,7 @@ describe("CipherFormGeneratorComponent", () => {
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
}) })
.overrideComponent(CipherFormGeneratorComponent, { .overrideComponent(CipherFormGeneratorComponent, {
remove: { imports: [PasswordGeneratorComponent, UsernameGeneratorComponent] }, remove: { imports: [GeneratorModule] },
add: { imports: [MockPasswordGeneratorComponent, MockUsernameGeneratorComponent] }, add: { imports: [MockPasswordGeneratorComponent, MockUsernameGeneratorComponent] },
}) })
.compileComponents(); .compileComponents();

View File

@@ -1,11 +1,7 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { SectionComponent } from "@bitwarden/components"; import { GeneratorModule } from "@bitwarden/generator-components";
import {
PasswordGeneratorComponent,
UsernameGeneratorComponent,
} from "@bitwarden/generator-components";
import { GeneratedCredential } from "@bitwarden/generator-core"; import { GeneratedCredential } from "@bitwarden/generator-core";
/** /**
@@ -16,7 +12,7 @@ import { GeneratedCredential } from "@bitwarden/generator-core";
selector: "vault-cipher-form-generator", selector: "vault-cipher-form-generator",
templateUrl: "./cipher-form-generator.component.html", templateUrl: "./cipher-form-generator.component.html",
standalone: true, standalone: true,
imports: [CommonModule, SectionComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], imports: [CommonModule, GeneratorModule],
}) })
export class CipherFormGeneratorComponent { export class CipherFormGeneratorComponent {
/** /**

View File

@@ -36,15 +36,6 @@
aria-readonly="true" aria-readonly="true"
data-testid="login-password" data-testid="login-password"
/> />
<button
*ngIf="cipher.viewPassword"
bitSuffix
type="button"
bitIconButton
bitPasswordInputToggle
data-testid="toggle-password"
(toggledChange)="pwToggleValue($event)"
></button>
<button <button
*ngIf="cipher.viewPassword && passwordRevealed" *ngIf="cipher.viewPassword && passwordRevealed"
bitIconButton="bwi-numbered-list" bitIconButton="bwi-numbered-list"
@@ -56,6 +47,15 @@
appStopClick appStopClick
(click)="togglePasswordCount()" (click)="togglePasswordCount()"
></button> ></button>
<button
*ngIf="cipher.viewPassword"
bitSuffix
type="button"
bitIconButton
bitPasswordInputToggle
data-testid="toggle-password"
(toggledChange)="pwToggleValue($event)"
></button>
<button <button
*ngIf="cipher.viewPassword" *ngIf="cipher.viewPassword"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"

1264
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -213,6 +213,7 @@
"zone.js": "$zone.js" "zone.js": "$zone.js"
}, },
"replacestream": "4.0.3", "replacestream": "4.0.3",
"@types/minimatch": "3.0.5",
"@electron/asar": { "@electron/asar": {
"@types/glob": "7.1.3" "@types/glob": "7.1.3"
} }