1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-02-03 11:27:26 -05:00
committed by GitHub
85 changed files with 4509 additions and 1456 deletions

9
.github/CODEOWNERS vendored
View File

@@ -15,6 +15,10 @@ apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-managemen
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml
# Web connectors
apps/web/src/connectors @bitwarden/team-auth-dev
apps/web/src/connectors/platform @bitwarden/team-platform-dev
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev
apps/cli/src/auth @bitwarden/team-auth-dev
@@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev
apps/web/src/app/auth @bitwarden/team-auth-dev
libs/auth @bitwarden/team-auth-dev
libs/user-core @bitwarden/team-auth-dev
# web connectors used for auth
apps/web/src/connectors @bitwarden/team-auth-dev
bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev
libs/angular/src/auth @bitwarden/team-auth-dev
libs/common/src/auth @bitwarden/team-auth-dev
@@ -154,6 +156,9 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev

View File

@@ -63,6 +63,11 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
steps:
- name: Log inputs to job summary
uses: bitwarden/ios/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@@ -181,6 +186,19 @@ jobs:
ref: ${{ steps.set-server-ref.outputs.server_ref }}
persist-credentials: false
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: sdk-internal
if_no_artifact_found: fail
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc-web"

View File

@@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask } from "@bitwarden/common/vault/tasks";
import AutofillPageDetails from "../../models/autofill-page-details";
import { NotificationTypes } from "../../notification/abstractions/notification-bar";
export type NotificationTypeData = {
isVaultLocked?: boolean;
@@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = {
uri: ModifyLoginCipherFormData["uri"];
};
/**
* Distinguished from `NotificationTypes` in that this represents the
* pre-resolved notification scenario, vs the notification component
* (e.g. "Add" and "Change" will be removed
* post-`useUndeterminedCipherScenarioTriggeringLogic` migration)
*/
export const NotificationScenarios = {
...NotificationTypes,
/** represents scenarios handling saving new and updated ciphers after form submit */
Cipher: "cipher",
} as const;
export type NotificationScenario =
(typeof NotificationScenarios)[keyof typeof NotificationScenarios];
export type WebsiteOriginsWithFields = Map<chrome.tabs.Tab["id"], Set<string>>;
export type ActiveFormSubmissionRequests = Set<chrome.webRequest.WebRequestDetails["requestId"]>;
/** This type represents an expectation of nullish values being represented as empty strings */
export type ModifyLoginCipherFormData = {
uri: string;
username: string;

View File

@@ -22,6 +22,7 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@@ -79,6 +80,30 @@ import {
} from "./abstractions/overlay-notifications.background";
import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background";
const inputScenarios = {
usernamePasswordNewPassword: "usernamePasswordNewPassword",
usernameNewPassword: "usernameNewPassword",
usernamePassword: "usernamePassword",
username: "username",
passwordNewPassword: "passwordNewPassword",
newPassword: "newPassword",
password: "password",
} as const;
type InputScenarioKey = keyof typeof inputScenarios;
type InputScenario = (typeof inputScenarios)[InputScenarioKey];
type CiphersByInputMatchCategory = {
allFieldMatches: CipherView["id"][];
newPasswordOnlyMatches: CipherView["id"][];
noFieldMatches: CipherView["id"][];
passwordNewPasswordMatches: CipherView["id"][];
passwordOnlyMatches: CipherView["id"][];
usernameNewPasswordMatches: CipherView["id"][];
usernameOnlyMatches: CipherView["id"][];
usernamePasswordMatches: CipherView["id"][];
};
export default class NotificationBackground {
private openUnlockPopout = openUnlockPopout;
private openAddEditVaultItemPopout = openAddEditVaultItemPopout;
@@ -152,6 +177,10 @@ export default class NotificationBackground {
this.cleanupNotificationQueue();
}
useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$(
FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic,
);
/**
* Gets the enableChangedPasswordPrompt setting from the user notification settings service.
*/
@@ -292,7 +321,7 @@ export default class NotificationBackground {
type: CipherType.Login,
reprompt,
favorite,
...(organizationCategories.length ? { organizationCategories } : {}),
...(organizationCategories.length > 0 ? { organizationCategories } : {}),
icon: buildCipherIcon(iconsServerUrl, view, showFavicons),
login: login && { username: login.username },
};
@@ -309,7 +338,7 @@ export default class NotificationBackground {
activeUserId: UserId,
): Promise<LoginSecurityTaskInfo | null> {
const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId);
if (!tasks?.length) {
if (!(tasks?.length > 0)) {
return null;
}
@@ -317,7 +346,7 @@ export default class NotificationBackground {
modifyLoginData.uri,
activeUserId,
);
if (!urlCiphers?.length) {
if (!(urlCiphers?.length > 0)) {
return null;
}
@@ -596,6 +625,216 @@ export default class NotificationBackground {
await this.checkNotificationQueue(tab);
}
/**
* Receives filled form values and determines if a notification should be
* triggered, and if so, what kind and with what data.
*
* If an update scenario is identified, a change password message is added to the
* notification queue, prompting the user to update a stored login that has changed.
*
* A new cipher notification is triggered in other defined scenarios
* with the user's form input.
*
* Returns `true` or `false` to indicate if such a notification was
* triggered or not.
*
* For the purposes of this function, form field inputs should be assumed to be
* qualified accurately.
*/
async triggerCipherNotification(
data: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
): Promise<boolean> {
const usernameFieldValue: string | null = data.username || null;
const currentPasswordFieldValue = data.password || null;
const newPasswordFieldValue = data.newPassword || null;
// If no values were entered, exit early
if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) {
return false;
}
// If the entered data doesn't have an associated URI, exit early
const loginDomain = Utils.getDomain(data.uri);
if (loginDomain === null) {
return false;
}
// If no cipher add/update notifications are enabled, we can exit early
const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt();
const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt();
if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) {
return false;
}
// If there is no account logged in (as opposed to only being locked), exit early
const authStatus = await this.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return false;
}
// If there is no active user, exit early
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId === null) {
return false;
}
const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : "";
const currentPasswordFieldHasValue =
typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0;
const newPasswordFieldHasValue =
typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0;
const usernameFieldHasValue =
typeof usernameFieldValue === "string" && usernameFieldValue.length > 0;
// If the current and new password inputs both have values and those values
// match, return early, since no change was made
if (
currentPasswordFieldHasValue &&
newPasswordFieldHasValue &&
currentPasswordFieldValue === newPasswordFieldValue
) {
return false;
}
/*
* We only show the unlock notification if a new password field was filled, since
* it's very likely to blindly represent an updated cipher value whereas other
* scenarios below require the vault to be unlocked in order to determine
* if an update has been made.
*/
if (authStatus === AuthenticationStatus.Locked) {
if (!newPasswordFieldHasValue) {
return false;
}
// This needs to be the call that includes the full form data
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
return true;
}
const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl(
data.uri,
activeUserId,
);
// Reducer structured to avoid subsequent array iterations
const ciphersByInputMatchCategory = ciphersForURL.reduce(
(acc, { id, login }) => {
const usernameInputMatchesCipher =
usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername;
const passwordInputMatchesCipher =
currentPasswordFieldHasValue && login.password === currentPasswordFieldValue;
const newPasswordInputMatchesCipher =
newPasswordFieldHasValue && login.password === newPasswordFieldValue;
if (
!newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
usernameInputMatchesCipher &&
passwordInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
!passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] };
} else if (
usernameInputMatchesCipher &&
passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] };
}
return acc;
},
{
allFieldMatches: [],
newPasswordOnlyMatches: [],
noFieldMatches: [],
passwordNewPasswordMatches: [],
passwordOnlyMatches: [],
usernameNewPasswordMatches: [],
usernameOnlyMatches: [],
usernamePasswordMatches: [],
},
);
// Handle different field fill combinations and determine the input scenario
const inputScenariosByKey = {
upn: inputScenarios.usernamePasswordNewPassword,
un: inputScenarios.usernameNewPassword,
up: inputScenarios.usernamePassword,
u: inputScenarios.username,
pn: inputScenarios.passwordNewPassword,
n: inputScenarios.newPassword,
p: inputScenarios.password,
} as const;
type InputScenarioKeys = keyof typeof inputScenariosByKey;
const key = ((usernameFieldHasValue ? "u" : "") +
(currentPasswordFieldHasValue ? "p" : "") +
(newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys;
const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null;
if (inputScenario) {
return await this.handleInputMatchScenario({
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
inputScenario,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
});
}
return false;
}
/**
* Adds a change password message to the notification queue, prompting the user
* to update the password for a login that has changed.
@@ -668,13 +907,14 @@ export default class NotificationBackground {
if (
ciphers.length > 0 &&
currentPasswordFieldValue?.length &&
(currentPasswordFieldValue?.length || 0) > 0 &&
// Only use current password for change if no new password present.
!newPasswordFieldValue
) {
const currentPasswordMatchesAnExistingValue = ciphers.some(
(cipher) =>
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
(cipher.login?.password?.length || 0) > 0 &&
cipher.login.password === currentPasswordFieldValue,
);
// The password entered matched a stored cipher value with
@@ -710,6 +950,213 @@ export default class NotificationBackground {
return false;
}
private async handleInputMatchScenario({
inputScenario,
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
}: {
ciphersByInputMatchCategory: CiphersByInputMatchCategory;
ciphersForURL: CipherView[];
loginDomain: string;
tab: chrome.tabs.Tab;
data: ModifyLoginCipherFormData;
inputScenario: InputScenario;
changePasswordNotificationIsEnabled: boolean;
newLoginNotificationIsEnabled: boolean;
}): Promise<boolean> {
const {
newPasswordOnlyMatches,
noFieldMatches,
passwordOnlyMatches,
usernameNewPasswordMatches,
usernameOnlyMatches,
usernamePasswordMatches,
} = ciphersByInputMatchCategory;
// IMPORTANT! The order of statements matters here; later evaluations
// depend on the assumptions of the early exits in preceding logic
// If no ciphers match any filled input values
// (Note, this block may uniquely exit early since this match scenario
// involves all ciphers, making it mutually exclusive from any other scenario)
if (noFieldMatches.length === ciphersForURL.length) {
// trigger a new cipher notification in these input scenarios
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
inputScenarios.username,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
// Trigger an update cipher notification with all URI ciphers
// in these input scenarios
if (
([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes(
inputScenario,
) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
ciphersForURL.map((c) => c.id),
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
return false;
}
// If ciphers match entered username and new password values
if (usernameNewPasswordMatches.length > 0) {
// Early exit in these scenarios as they represent "no change"
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
}
// If ciphers match entered username and password values
if (usernamePasswordMatches.length > 0) {
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernamePasswordMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
if (inputScenario === inputScenarios.usernamePassword) {
return false;
}
}
// If ciphers match entered username value (only)
if (usernameOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernameOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.username) {
return false;
}
}
// If ciphers match entered new password value (only)
if (newPasswordOnlyMatches.length > 0) {
// Early exit in these scenarios
if (
(
[
inputScenarios.usernameNewPassword, // unclear user expectation
inputScenarios.password, // likely nothing to change
inputScenarios.newPassword, // nothing to change
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
}
// If ciphers match entered password value (only)
if (passwordOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernamePassword,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
passwordOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.password) {
return false;
}
}
return false;
}
/**
* Sends the page details to the notification bar. Will query all
* forms with a password field and pass them to the notification bar.
@@ -730,6 +1177,7 @@ export default class NotificationBackground {
});
}
// @TODO this needs the whole input record, and not just newPassword
private async pushChangePasswordToQueue(
cipherIds: CipherView["id"][],
loginDomain: string,

View File

@@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => {
jest.useFakeTimers();
logService = mock<LogService>();
notificationBackground = mock<NotificationBackground>();
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
.mockResolvedValue(true);
@@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
let notificationChangedPasswordSpy: jest.SpyInstance;
let notificationAddLoginSpy: jest.SpyInstance;
let cipherNotificationSpy: jest.SpyInstance;
beforeEach(async () => {
sender = mock<chrome.runtime.MessageSender>({
@@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => {
"triggerChangedPasswordNotification",
);
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification");
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
@@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
beforeEach(async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
@@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "loading",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: sender.tab.id,
url: sender.url,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("initializes the notification immediately when the tab's navigation is complete", async () => {
sendMockExtensionMessage(
{
@@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
@@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationChangedPasswordSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: "https://example.com/redirect",
tabId: sender.tab.id,
method: "GET",
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,9 @@
import { Subject, switchMap, timer } from "rxjs";
import { firstValueFrom, Subject, switchMap, timer } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar";
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
import {
@@ -14,6 +13,8 @@ import {
OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface,
OverlayNotificationsExtensionMessage,
OverlayNotificationsExtensionMessageHandlers,
NotificationScenarios,
NotificationScenario,
WebsiteOriginsWithFields,
} from "./abstractions/overlay-notifications.background";
import NotificationBackground from "./notification.background";
@@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
collectPageDetailsResponse: ({ message, sender }) =>
this.handleCollectPageDetailsResponse(message, sender),
};
constructor(
private logService: LogService,
private notificationBackground: NotificationBackground,
@@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
NotificationScenarios.Add,
);
if (shouldAttemptAddNotification) {
@@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
NotificationScenarios.Change,
);
if (shouldAttemptChangeNotification) {
@@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
requestId: chrome.webRequest.WebRequestDetails["requestId"],
modifyLoginData: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
config: { skippable: NotificationType[] } = { skippable: [] },
config: { skippable: NotificationScenario[] } = { skippable: [] },
) => {
const notificationCandidates = [
{
type: NotificationTypes.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationTypes.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationTypes.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
].filter(
const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom(
this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$,
);
const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic
? [
{
type: NotificationScenarios.Cipher,
trigger: this.notificationBackground.triggerCipherNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
]
: [
{
type: NotificationScenarios.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationScenarios.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
];
const filteredNotificationCandidates = notificationCandidates.filter(
(candidate) =>
this.shouldAttemptNotification(modifyLoginData, candidate.type) ||
config.skippable.includes(candidate.type),
);
const results: string[] = [];
for (const { trigger, type } of notificationCandidates) {
for (const { trigger, type } of filteredNotificationCandidates) {
const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab);
if (success) {
results.push(`Success: ${type}`);
@@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private shouldAttemptNotification = (
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
notificationType: NotificationScenario,
): boolean => {
if (notificationType === NotificationScenarios.Cipher) {
// The logic after this block pre-qualifies some cipher add/update scenarios
// prematurely (where matching against vault contents is required) and should be
// skipped for this case (these same checks are performed early in the
// notification triggering logic).
return true;
}
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
@@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
case NotificationScenarios.Add:
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
case NotificationScenarios.Change:
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
case NotificationScenarios.AtRiskPassword:
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
case NotificationScenarios.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;
default:

View File

@@ -8,9 +8,13 @@ import {
} from "../../../autofill/content/components/common-types";
const NotificationTypes = {
/** represents scenarios handling saving new ciphers after form submit */
Add: "add",
/** represents scenarios handling saving updated ciphers after form submit */
Change: "change",
/** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */
Unlock: "unlock",
/** represents scenarios where the user has security tasks after updating ciphers */
AtRiskPassword: "at-risk-password",
} as const;

View File

@@ -110,7 +110,7 @@
<bit-item>
<a bit-item-content routerLink="/download-bitwarden">
<i slot="start" class="bwi bwi-mobile" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<div class="tw-flex tw-items-center">
<p class="tw-pr-2">{{ "downloadBitwardenOnAllDevices" | i18n }}</p>
<span
*ngIf="showDownloadBitwardenNudge$ | async"

View File

@@ -5,7 +5,8 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
isAutofillList
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, Observable } from "rxjs";
import { combineLatest, map, Observable, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -42,6 +42,12 @@ export class AutofillVaultListItemsComponent {
*/
protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window);
/** Flag indicating whether the login item should automatically autofill when clicked */
protected clickItemsToAutofillVaultView$: Observable<boolean> =
this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe(
startWith(true), // Start with true to avoid flashing the fill button on first load
);
protected readonly groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);

View File

@@ -8,14 +8,14 @@
></button>
<bit-menu #moreOptions>
@if (!decryptionFailure) {
<ng-container *ngIf="canAutofill && showAutofill()">
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
</ng-container>
</ng-container>
<ng-container>
<ng-container *ngIf="showViewOption">
<button type="button" bitMenuItem (click)="onView()">
{{ "view" | i18n }}
</button>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, input, Input } from "@angular/core";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
@@ -76,10 +76,22 @@ export class ItemMoreOptionsComponent {
}
/**
* Flag to show the autofill menu options. Used for items that are
* Flag to show view item menu option. Used when something else is
* assigned as the primary action for the item, such as autofill.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
showViewOption = false;
/**
* Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
readonly showAutofill = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions = false;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;

View File

@@ -90,11 +90,11 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<button
bit-item-content
type="button"
(click)="onCipherSelect(cipher)"
(click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="
cipherItemTitleKey()(cipher)
@@ -125,14 +125,19 @@
</button>
<ng-container slot="end">
<bit-item-action *ngIf="isAutofillList()">
<span
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100"
<bit-item-action *ngIf="!hideAutofillButton()">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</span>
</button>
</bit-item-action>
<bit-item-action *ngIf="!isAutofillList() && CipherViewLikeUtils.canLaunch(cipher)">
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<button
type="button"
bitIconButton="bwi-external-link"
@@ -144,7 +149,8 @@
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[showAutofill]="!isAutofillList()"
[hideAutofillOptions]="hideAutofillMenuOptions()"
[showViewOption]="primaryActionAutofill()"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -136,18 +136,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
readonly ciphers = input<PopupCipherViewLike[]>([]);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
readonly groupByType = input<boolean | undefined>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
readonly cipherGroups = computed<
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -189,7 +195,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
readonly title = input<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -197,20 +205,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
readonly description = input<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
readonly showRefresh = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -223,16 +235,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
readonly cipherItemTitleKey = computed(() => {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
const key = !this.currentUriIsBlocked() ? "autofillTitle" : "viewItemTitle";
const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle"
: "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
@@ -240,25 +259,47 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
readonly isAutofillList = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Computed property whether the cipher select action should perform autofill
* Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
readonly shouldAutofillOnSelect = computed(
() => this.isAutofillList() && !this.currentUriIsBlocked(),
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
/**
* Flag indicating whether the cipher item autofill menu options should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
/**
* Option to perform autofill operation as the primary action for autofill suggestions.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -272,7 +313,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(
private i18nService: I18nService,
@@ -297,8 +340,10 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
}
}
onCipherSelect(cipher: PopupCipherViewLike) {
return this.shouldAutofillOnSelect() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
primaryActionOnSelect(cipher: PopupCipherViewLike) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher)
: this.onViewCipher(cipher);
}
/**

View File

@@ -50,10 +50,16 @@
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<bit-form-control>
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />
<bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
<bit-label>
{{ "clickToAutofill" | i18n }}
</bit-label>
</bit-form-control>
</bit-card>
</form>
</popup-page>

View File

@@ -59,12 +59,14 @@ describe("AppearanceV2Component", () => {
const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true);
const enableCompactMode$ = new BehaviorSubject<boolean>(false);
const showQuickCopyActions$ = new BehaviorSubject<boolean>(false);
const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(false);
const setSelectedTheme = jest.fn().mockResolvedValue(undefined);
const setShowFavicons = jest.fn().mockResolvedValue(undefined);
const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined);
const setEnableRoutingAnimation = jest.fn().mockResolvedValue(undefined);
const setEnableCompactMode = jest.fn().mockResolvedValue(undefined);
const setShowQuickCopyActions = jest.fn().mockResolvedValue(undefined);
const setClickItemsToAutofillVaultView = jest.fn().mockResolvedValue(undefined);
const mockWidthService: Partial<PopupSizeService> = {
width$: new BehaviorSubject("default"),
@@ -111,7 +113,10 @@ describe("AppearanceV2Component", () => {
},
{
provide: VaultSettingsService,
useValue: mock<VaultSettingsService>(),
useValue: {
clickItemsToAutofillVaultView$,
setClickItemsToAutofillVaultView,
},
},
],
})
@@ -142,6 +147,7 @@ describe("AppearanceV2Component", () => {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default",
clickItemsToAutofillVaultView: false,
});
});
@@ -187,5 +193,11 @@ describe("AppearanceV2Component", () => {
expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide");
});
it("updates the click items to autofill vault view setting", () => {
component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
});
});
});

View File

@@ -66,6 +66,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default" as PopupWidthOption,
clickItemsToAutofillVaultView: false,
});
/** To avoid flashes of inaccurate values, only show the form after the entire form is populated. */
@@ -111,6 +112,9 @@ export class AppearanceV2Component implements OnInit {
this.copyButtonsService.showQuickCopyActions$,
);
const width = await firstValueFrom(this.popupSizeService.width$);
const clickItemsToAutofillVaultView = await firstValueFrom(
this.vaultSettingsService.clickItemsToAutofillVaultView$,
);
// Set initial values for the form
this.appearanceForm.setValue({
@@ -121,6 +125,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode,
showQuickCopyActions,
width,
clickItemsToAutofillVaultView,
});
this.formLoading = false;
@@ -166,6 +171,16 @@ export class AppearanceV2Component implements OnInit {
.subscribe((width) => {
void this.updateWidth(width);
});
this.appearanceForm.controls.clickItemsToAutofillVaultView.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((clickItemsToAutofillVaultView) => {
void this.updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
});
}
async updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView: boolean) {
await this.vaultSettingsService.setClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
}
async updateFavicon(enableFavicon: boolean) {

View File

@@ -0,0 +1,332 @@
#[napi]
pub mod autofill {
use desktop_core::ipc::server::{Message, MessageType};
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::error;
#[napi]
pub async fn run_command(value: String) -> napi::Result<String> {
desktop_core::autofill::run_command(value)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[derive(Debug, serde::Serialize, serde:: Deserialize)]
pub enum BitwardenError {
Internal(String),
}
#[napi(string_enum)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
#[napi(value = "preferred")]
Preferred,
#[napi(value = "required")]
Required,
#[napi(value = "discouraged")]
Discouraged,
}
#[derive(Serialize, Deserialize)]
#[serde(bound = "T: Serialize + DeserializeOwned")]
pub struct PasskeyMessage<T: Serialize + DeserializeOwned> {
pub sequence_number: u32,
pub value: Result<T, BitwardenError>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
pub rp_id: String,
pub user_name: String,
pub user_handle: Vec<u8>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
pub excluded_credentials: Vec<Vec<u8>>,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub credential_id: Vec<u8>,
pub attestation_object: Vec<u8>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
pub rp_id: String,
pub credential_id: Vec<u8>,
pub user_name: String,
pub user_handle: Vec<u8>,
pub record_identifier: Option<String>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
pub key: String,
pub value: String,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
pub rp_id: String,
pub user_handle: Vec<u8>,
pub signature: Vec<u8>,
pub client_data_hash: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub credential_id: Vec<u8>,
}
#[napi]
pub struct AutofillIpcServer {
server: desktop_core::ipc::server::Server,
}
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
#[napi]
impl AutofillIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
/// connection and must be the same for both the server and client. @param callback
/// This function will be called whenever a message is received from a client.
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi(factory)]
pub async fn listen(
name: String,
// Ideally we'd have a single callback that has an enum containing the request values,
// but NAPI doesn't support that just yet
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void"
)]
registration_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyRegistrationRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void"
)]
assertion_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyAssertionRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
)]
assertion_without_user_interface_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
)]
native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
while let Some(Message {
client_id,
kind,
message,
}) = recv.recv().await
{
match kind {
// TODO: We're ignoring the connection and disconnection messages for now
MessageType::Connected | MessageType::Disconnected => continue,
MessageType::Message => {
let Some(message) = message else {
error!("Message is empty");
continue;
};
match serde_json::from_str::<PasskeyMessage<PasskeyAssertionRequest>>(
&message,
) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message1");
}
}
match serde_json::from_str::<
PasskeyMessage<PasskeyAssertionWithoutUserInterfaceRequest>,
>(&message)
{
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_without_user_interface_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message1");
}
}
match serde_json::from_str::<PasskeyMessage<PasskeyRegistrationRequest>>(
&message,
) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
registration_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message2");
}
}
match serde_json::from_str::<PasskeyMessage<NativeStatus>>(&message) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
native_status_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(error) => {
error!(%error, "Unable to deserialze native status.");
}
}
error!(message, "Received an unknown message2");
}
}
}
});
let path = desktop_core::ipc::path(&name);
let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
napi::Error::from_reason(format!(
"Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
))
})?;
Ok(AutofillIpcServer { server })
}
/// Return the path to the IPC server.
#[napi]
pub fn get_path(&self) -> String {
self.server.path.to_string_lossy().to_string()
}
/// Stop the IPC server.
#[napi]
pub fn stop(&self) -> napi::Result<()> {
self.server.stop();
Ok(())
}
#[napi]
pub fn complete_registration(
&self,
client_id: u32,
sequence_number: u32,
response: PasskeyRegistrationResponse,
) -> napi::Result<u32> {
let message = PasskeyMessage {
sequence_number,
value: Ok(response),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
#[napi]
pub fn complete_assertion(
&self,
client_id: u32,
sequence_number: u32,
response: PasskeyAssertionResponse,
) -> napi::Result<u32> {
let message = PasskeyMessage {
sequence_number,
value: Ok(response),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
#[napi]
pub fn complete_error(
&self,
client_id: u32,
sequence_number: u32,
error: String,
) -> napi::Result<u32> {
let message: PasskeyMessage<()> = PasskeyMessage {
sequence_number,
value: Err(BitwardenError::Internal(error)),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
// TODO: Add a way to send a message to a specific client?
fn send(&self, _client_id: u32, message: String) -> napi::Result<u32> {
self.server
.send(message)
.map_err(|e| {
napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
})
// NAPI doesn't support u64 or usize, so we need to convert to u32
.map(|u| u32::try_from(u).unwrap_or_default())
}
}
}

View File

@@ -0,0 +1,9 @@
#[napi]
pub mod autostart {
#[napi]
pub async fn set_autostart(autostart: bool, params: Vec<String>) -> napi::Result<()> {
desktop_core::autostart::set_autostart(autostart, params)
.await
.map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}")))
}
}

View File

@@ -0,0 +1,20 @@
#[napi]
pub mod autotype {
#[napi]
pub fn get_foreground_window_title() -> napi::Result<String> {
autotype::get_foreground_window_title().map_err(|_| {
napi::Error::from_reason(
"Autotype Error: failed to get foreground window title".to_string(),
)
})
}
#[napi]
pub fn type_input(
input: Vec<u16>,
keyboard_shortcut: Vec<String>,
) -> napi::Result<(), napi::Status> {
autotype::type_input(&input, &keyboard_shortcut)
.map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}")))
}
}

View File

@@ -0,0 +1,100 @@
#[napi]
pub mod biometrics {
use desktop_core::biometric::{Biometric, BiometricTrait};
// Prompt for biometric confirmation
#[napi]
pub async fn prompt(
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
Biometric::prompt(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn available() -> napi::Result<bool> {
Biometric::available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn set_biometric_secret(
service: String,
account: String,
secret: String,
key_material: Option<KeyMaterial>,
iv_b64: String,
) -> napi::Result<String> {
Biometric::set_biometric_secret(
&service,
&account,
&secret,
key_material.map(|m| m.into()),
&iv_b64,
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Retrieves the biometric secret for the given service and account.
/// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
#[napi]
pub async fn get_biometric_secret(
service: String,
account: String,
key_material: Option<KeyMaterial>,
) -> napi::Result<String> {
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Derives key material from biometric data. Returns a string encoded with a
/// base64 encoded key and the base64 encoded challenge used to create it
/// separated by a `|` character.
///
/// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
/// be generated.
///
/// `format!("<key_base64>|<iv_base64>")`
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn derive_key_material(iv: Option<String>) -> napi::Result<OsDerivedKey> {
Biometric::derive_key_material(iv.as_deref())
.map(|k| k.into())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi(object)]
pub struct KeyMaterial {
pub os_key_part_b64: String,
pub client_key_part_b64: Option<String>,
}
impl From<KeyMaterial> for desktop_core::biometric::KeyMaterial {
fn from(km: KeyMaterial) -> Self {
desktop_core::biometric::KeyMaterial {
os_key_part_b64: km.os_key_part_b64,
client_key_part_b64: km.client_key_part_b64,
}
}
}
#[napi(object)]
pub struct OsDerivedKey {
pub key_b64: String,
pub iv_b64: String,
}
impl From<desktop_core::biometric::OsDerivedKey> for OsDerivedKey {
fn from(km: desktop_core::biometric::OsDerivedKey) -> Self {
OsDerivedKey {
key_b64: km.key_b64,
iv_b64: km.iv_b64,
}
}
}
}

View File

@@ -0,0 +1,116 @@
#[napi]
pub mod biometrics_v2 {
use desktop_core::biometric_v2::BiometricTrait;
#[napi]
pub struct BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem,
}
#[napi]
pub fn init_biometric_system() -> napi::Result<BiometricLockSystem> {
Ok(BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
})
}
#[napi]
pub async fn authenticate(
biometric_lock_system: &BiometricLockSystem,
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn authenticate_available(
biometric_lock_system: &BiometricLockSystem,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn enroll_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.enroll_persistent(&user_id, &key)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn provide_key(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.provide_key(&user_id, &key)
.await;
Ok(())
}
#[napi]
pub async fn unlock(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
hwnd: napi::bindgen_prelude::Buffer,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
biometric_lock_system
.inner
.unlock(&user_id, hwnd.into())
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.into())
}
#[napi]
pub async fn unlock_available(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.unlock_available(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn has_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.has_persistent(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn unenroll(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<()> {
biometric_lock_system
.inner
.unenroll(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,116 @@
#[napi]
pub mod chromium_importer {
use std::collections::HashMap;
use chromium_importer::{
chromium::{
DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult,
ProfileInfo as _ProfileInfo,
},
metadata::NativeImporterMetadata as _NativeImporterMetadata,
};
#[napi(object)]
pub struct ProfileInfo {
pub id: String,
pub name: String,
}
#[napi(object)]
pub struct Login {
pub url: String,
pub username: String,
pub password: String,
pub note: String,
}
#[napi(object)]
pub struct LoginImportFailure {
pub url: String,
pub username: String,
pub error: String,
}
#[napi(object)]
pub struct LoginImportResult {
pub login: Option<Login>,
pub failure: Option<LoginImportFailure>,
}
#[napi(object)]
pub struct NativeImporterMetadata {
pub id: String,
pub loaders: Vec<String>,
pub instructions: String,
}
impl From<_LoginImportResult> for LoginImportResult {
fn from(l: _LoginImportResult) -> Self {
match l {
_LoginImportResult::Success(l) => LoginImportResult {
login: Some(Login {
url: l.url,
username: l.username,
password: l.password,
note: l.note,
}),
failure: None,
},
_LoginImportResult::Failure(l) => LoginImportResult {
login: None,
failure: Some(LoginImportFailure {
url: l.url,
username: l.username,
error: l.error,
}),
},
}
}
}
impl From<_ProfileInfo> for ProfileInfo {
fn from(p: _ProfileInfo) -> Self {
ProfileInfo {
id: p.folder,
name: p.name,
}
}
}
impl From<_NativeImporterMetadata> for NativeImporterMetadata {
fn from(m: _NativeImporterMetadata) -> Self {
NativeImporterMetadata {
id: m.id,
loaders: m.loaders,
instructions: m.instructions,
}
}
}
#[napi]
/// Returns OS aware metadata describing supported Chromium based importers as a JSON string.
pub fn get_metadata() -> HashMap<String, NativeImporterMetadata> {
chromium_importer::metadata::get_supported_importers::<DefaultInstalledBrowserRetriever>()
.into_iter()
.map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata)))
.collect()
}
#[napi]
pub fn get_available_profiles(browser: String) -> napi::Result<Vec<ProfileInfo>> {
chromium_importer::chromium::get_available_profiles(&browser)
.map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn import_logins(
browser: String,
profile_id: String,
) -> napi::Result<Vec<LoginImportResult>> {
chromium_importer::chromium::import_logins(&browser, &profile_id)
.await
.map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,15 @@
#[napi]
pub mod clipboards {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn read() -> napi::Result<String> {
desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn write(text: String, password: bool) -> napi::Result<()> {
desktop_core::clipboard::write(&text, password)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,106 @@
#[napi]
pub mod ipc {
use desktop_core::ipc::server::{Message, MessageType};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
#[napi(object)]
pub struct IpcMessage {
pub client_id: u32,
pub kind: IpcMessageType,
pub message: Option<String>,
}
impl From<Message> for IpcMessage {
fn from(message: Message) -> Self {
IpcMessage {
client_id: message.client_id,
kind: message.kind.into(),
message: message.message,
}
}
}
#[napi]
pub enum IpcMessageType {
Connected,
Disconnected,
Message,
}
impl From<MessageType> for IpcMessageType {
fn from(message_type: MessageType) -> Self {
match message_type {
MessageType::Connected => IpcMessageType::Connected,
MessageType::Disconnected => IpcMessageType::Disconnected,
MessageType::Message => IpcMessageType::Message,
}
}
}
#[napi]
pub struct NativeIpcServer {
server: desktop_core::ipc::server::Server,
}
#[napi]
impl NativeIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
/// connection and must be the same for both the server and client. @param callback
/// This function will be called whenever a message is received from a client.
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi(factory)]
pub async fn listen(
name: String,
#[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")]
callback: ThreadsafeFunction<IpcMessage>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
while let Some(message) = recv.recv().await {
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
let path = desktop_core::ipc::path(&name);
let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
napi::Error::from_reason(format!(
"Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
))
})?;
Ok(NativeIpcServer { server })
}
/// Return the path to the IPC server.
#[napi]
pub fn get_path(&self) -> String {
self.server.path.to_string_lossy().to_string()
}
/// Stop the IPC server.
#[napi]
pub fn stop(&self) -> napi::Result<()> {
self.server.stop();
Ok(())
}
/// Send a message over the IPC server to all the connected clients
///
/// @return The number of clients that the message was sent to. Note that the number of
/// messages actually received may be less, as some clients could disconnect before
/// receiving the message.
#[napi]
pub fn send(&self, message: String) -> napi::Result<u32> {
self.server
.send(message)
.map_err(|e| {
napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
})
// NAPI doesn't support u64 or usize, so we need to convert to u32
.map(|u| u32::try_from(u).unwrap_or_default())
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
#[napi]
pub mod logging {
//! `logging` is the interface between the native desktop's usage of the `tracing` crate
//! for logging, to intercept events and write to the JS space.
//!
//! # Example
//!
//! [Elec] 14:34:03.517 [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting
//! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock}
use std::{fmt::Write, sync::OnceLock};
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tracing::Level;
use tracing_subscriber::{
filter::EnvFilter,
fmt::format::{DefaultVisitor, Writer},
layer::SubscriberExt,
util::SubscriberInitExt,
Layer,
};
struct JsLogger(OnceLock<ThreadsafeFunction<FnArgs<(LogLevel, String)>>>);
static JS_LOGGER: JsLogger = JsLogger(OnceLock::new());
#[napi]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl From<&Level> for LogLevel {
fn from(level: &Level) -> Self {
match *level {
Level::TRACE => LogLevel::Trace,
Level::DEBUG => LogLevel::Debug,
Level::INFO => LogLevel::Info,
Level::WARN => LogLevel::Warn,
Level::ERROR => LogLevel::Error,
}
}
}
// JsLayer lets us intercept events and write them to the JS Logger.
struct JsLayer;
impl<S> Layer<S> for JsLayer
where
S: tracing::Subscriber,
{
// This function builds a log message buffer from the event data and
// calls the JS logger with it.
//
// For example, this log call:
//
// ```
// mod supreme {
// mod module {
// let foo = "bar";
// info!(best_variable_name = %foo, "Foo done it again.");
// }
// }
// ```
//
// , results in the following string:
//
// [INFO] supreme::module: Foo done it again. {best_variable_name=bar}
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut buffer = String::new();
// create the preamble text that precedes the message and vars. e.g.:
// [INFO] desktop_core::ssh_agent::platform_ssh_agent:
let level = event.metadata().level().as_str();
let module_path = event.metadata().module_path().unwrap_or_default();
write!(&mut buffer, "[{level}] {module_path}:")
.expect("Failed to write tracing event to buffer");
let writer = Writer::new(&mut buffer);
// DefaultVisitor adds the message and variables to the buffer
let mut visitor = DefaultVisitor::new(writer, false);
event.record(&mut visitor);
let msg = (event.metadata().level().into(), buffer);
if let Some(logger) = JS_LOGGER.0.get() {
let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking);
};
}
}
#[napi]
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<FnArgs<(LogLevel, String)>>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
// - if RUST_LOG is detected at runtime
// - if RUST_LOG is provided at compile time
// - default to INFO
let filter = EnvFilter::builder()
.with_default_directive(
option_env!("RUST_LOG")
.unwrap_or("info")
.parse()
.expect("should provide valid log level at compile time."),
)
// parse directives from the RUST_LOG environment variable,
// overriding the default directive for matching targets.
.from_env_lossy();
// With the `tracing-log` feature enabled for the `tracing_subscriber`,
// the registry below will initialize a log compatibility layer, which allows
// the subscriber to consume log::Records as though they were tracing Events.
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init
tracing_subscriber::registry()
.with(filter)
.with(JsLayer)
.init();
}
}

View File

@@ -0,0 +1,9 @@
#[napi]
pub mod passkey_authenticator {
#[napi]
pub fn register() -> napi::Result<()> {
crate::passkey_authenticator_internal::register().map_err(|e| {
napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}"))
})
}
}

View File

@@ -0,0 +1,46 @@
#[napi]
pub mod passwords {
/// The error message returned when a password is not found during retrieval or deletion.
#[napi]
pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
/// Fetch the stored password from the keychain.
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
#[napi]
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
desktop_core::password::get_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Save the password to the keychain. Adds an entry if none exists otherwise updates the
/// existing entry.
#[napi]
pub async fn set_password(
service: String,
account: String,
password: String,
) -> napi::Result<()> {
desktop_core::password::set_password(&service, &account, &password)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Delete the stored password from the keychain.
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
#[napi]
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
desktop_core::password::delete_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Checks if the os secure storage is available
#[napi]
pub async fn is_available() -> napi::Result<bool> {
desktop_core::password::is_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,26 @@
#[napi]
pub mod powermonitors {
use napi::{
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
tokio,
};
#[napi]
pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
desktop_core::powermonitor::on_lock(tx)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
tokio::spawn(async move {
while let Some(()) = rx.recv().await {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
Ok(())
}
#[napi]
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
}
}

View File

@@ -0,0 +1,23 @@
#[napi]
pub mod processisolations {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn disable_coredumps() -> napi::Result<()> {
desktop_core::process_isolation::disable_coredumps()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn is_core_dumping_disabled() -> napi::Result<bool> {
desktop_core::process_isolation::is_core_dumping_disabled()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn isolate_process() -> napi::Result<()> {
desktop_core::process_isolation::isolate_process()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,163 @@
#[napi]
pub mod sshagent {
use std::sync::Arc;
use napi::{
bindgen_prelude::Promise,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tokio::{self, sync::Mutex};
use tracing::error;
#[napi]
pub struct SshAgentState {
state: desktop_core::ssh_agent::BitwardenDesktopAgent,
}
#[napi(object)]
pub struct PrivateKey {
pub private_key: String,
pub name: String,
pub cipher_id: String,
}
#[napi(object)]
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[napi(object)]
pub struct SshUIRequest {
pub cipher_id: Option<String>,
pub is_list: bool,
pub process_name: String,
pub is_forwarding: bool,
pub namespace: Option<String>,
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn serve(
callback: ThreadsafeFunction<SshUIRequest, Promise<bool>>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32);
let (auth_response_tx, auth_response_rx) =
tokio::sync::broadcast::channel::<(u32, bool)>(32);
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
// Wrap callback in Arc so it can be shared across spawned tasks
let callback = Arc::new(callback);
tokio::spawn(async move {
let _ = auth_response_rx;
while let Some(request) = auth_request_rx.recv().await {
let cloned_response_tx_arc = auth_response_tx_arc.clone();
let cloned_callback = callback.clone();
tokio::spawn(async move {
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
// In NAPI v3, obtain the JS callback return as a Promise<boolean> and await it
// in Rust
let (tx, rx) = std::sync::mpsc::channel::<Promise<bool>>();
let status = callback.call_with_return_value(
Ok(SshUIRequest {
cipher_id: request.cipher_id,
is_list: request.is_list,
process_name: request.process_name,
is_forwarding: request.is_forwarding,
namespace: request.namespace,
}),
ThreadsafeFunctionCallMode::Blocking,
move |ret: Result<Promise<bool>, napi::Error>, _env| {
if let Ok(p) = ret {
let _ = tx.send(p);
}
Ok(())
},
);
let result = if status == napi::Status::Ok {
match rx.recv() {
Ok(promise) => match promise.await {
Ok(v) => v,
Err(e) => {
error!(error = %e, "UI callback promise rejected");
false
}
},
Err(e) => {
error!(error = %e, "Failed to receive UI callback promise");
false
}
}
} else {
error!(error = ?status, "Calling UI callback failed");
false
};
let _ = auth_response_tx_arc
.lock()
.await
.send((request.request_id, result))
.expect("should be able to send auth response to agent");
});
}
});
match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
auth_request_tx,
Arc::new(Mutex::new(auth_response_rx)),
) {
Ok(state) => Ok(SshAgentState { state }),
Err(e) => Err(napi::Error::from_reason(e.to_string())),
}
}
#[napi]
pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state.stop();
Ok(())
}
#[napi]
pub fn is_running(agent_state: &mut SshAgentState) -> bool {
let bitwarden_agent_state = agent_state.state.clone();
bitwarden_agent_state.is_running()
}
#[napi]
pub fn set_keys(
agent_state: &mut SshAgentState,
new_keys: Vec<PrivateKey>,
) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.set_keys(
new_keys
.iter()
.map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
.collect(),
)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(())
}
#[napi]
pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.lock()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.clear_keys()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,16 @@
#[napi]
pub mod windows_registry {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> {
crate::registry::create_key(&key, &subkey, &value)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> {
crate::registry::delete_key(&key, &subkey)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -97,7 +97,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
async ngOnChanges(changes: SimpleChanges) {
if (changes.cipher) {
if (changes.cipher || changes.action) {
await this.checkArchiveState();
}
}
@@ -255,12 +255,15 @@ export class ItemFooterComponent implements OnInit, OnChanges {
this.userCanArchive = userCanArchive;
this.showArchiveButton =
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
cipherCanBeArchived &&
userCanArchive &&
(this.action === "view" || this.action === "edit") &&
!this.cipher.isArchived;
// A user should always be able to unarchive an archived item
this.showUnarchiveButton =
hasArchiveFlagEnabled &&
this.action === "view" &&
(this.action === "view" || this.action === "edit") &&
this.cipher.isArchived &&
!this.cipher.isDeleted;
}

View File

@@ -121,7 +121,7 @@
}}"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
<i class="bwi bwi-fw {{ getIconString(organization) }}" aria-hidden="true"></i>
&nbsp;{{ organization.name }}
</button>
<span *ngIf="!organization.enabled" class="tw-ml-auto">

View File

@@ -5,6 +5,7 @@ import { Component } from "@angular/core";
import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
@@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent
});
}
}
getIconString(organization: Organization): string {
if (
organization?.productTierType === ProductTierType.Free ||
organization?.productTierType === ProductTierType.Families
) {
return "bwi-family";
} else {
return "bwi-business";
}
}
}

View File

@@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \
rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \
fi
# Override SDK if custom artifacts are present
RUN if [ -d "sdk-internal" ]; then \
echo "Overriding SDK with custom artifacts from sdk-internal" ; \
npm link ./sdk-internal ; \
fi
WORKDIR /source/apps/web
ARG NPM_COMMAND=dist:bit:selfhost
RUN npm run ${NPM_COMMAND}

View File

@@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserResetPasswordDetailsResponse,
OrganizationUserResetPasswordRequest,
} from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationData,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfType, KeyService } from "@bitwarden/key-management";
import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
@@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => {
let i18nService: MockProxy<I18nService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let configService: MockProxy<ConfigService>;
beforeAll(() => {
keyService = mock<KeyService>();
@@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService = mock<OrganizationApiService>();
i18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
masterPasswordService = new FakeMasterPasswordService();
configService = mock<ConfigService>();
sut = new OrganizationUserResetPasswordService(
keyService,
@@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService,
i18nService,
accountService,
masterPasswordService,
configService,
);
});
@@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("resetMasterPassword", () => {
/**
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are
* any imports/properties in the test setup above that are now un-used and can also be removed.
*/
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false;
const mockNewMP = "new-password";
const mockEmail = "test@example.com";
const mockOrgUserId = "test-org-user-id";
const mockOrgId = "test-org-id";
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
);
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
new OrganizationUserResetPasswordDetailsResponse({
kdf: KdfType.PBKDF2_SHA256,
@@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
// Mock sut method parameters
const newMasterPassword = "new-master-password";
const email = "user@example.com";
const orgUserId = "org-user-id";
const orgId = "org-id" as OrganizationId;
// Mock feature flag value
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true;
// Mock method data
let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse;
let salt: MasterPasswordSalt;
let kdfConfig: KdfConfig;
let authenticationData: MasterPasswordAuthenticationData;
let unlockData: MasterPasswordUnlockData;
let userKey: UserKey;
beforeEach(() => {
// Mock feature flag value
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
);
// Mock method data
kdfConfig = DEFAULT_KDF_CONFIG;
organizationUserResetPasswordDetailsResponse =
new OrganizationUserResetPasswordDetailsResponse({
organizationUserId: orgUserId,
kdf: kdfConfig.kdfType,
kdfIterations: kdfConfig.iterations,
resetPasswordKey: "test-reset-password-key",
encryptedPrivateKey: "test-encrypted-private-key",
});
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
organizationUserResetPasswordDetailsResponse,
);
const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1);
const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey;
keyService.orgKeys$.mockReturnValue(
of({ [orgId]: mockDecryptedOrgKey } as Record<OrganizationId, OrgKey>),
);
const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2);
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes);
const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3);
const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes);
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey`
userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey.
salt = email as MasterPasswordSalt;
masterPasswordService.mock.emailToSalt.mockReturnValue(salt);
authenticationData = {
salt,
kdf: kdfConfig,
masterPasswordAuthenticationHash:
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
};
unlockData = {
salt,
kdf: kdfConfig,
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
} as MasterPasswordUnlockData;
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
authenticationData,
);
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
});
it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => {
// Arrange
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow();
});
it("should throw an error if the org key cannot be found", async () => {
// Arrange
keyService.orgKeys$.mockReturnValue(of({} as Record<OrganizationId, OrgKey>));
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow("No org key found");
});
it("should throw an error if orgKeys$ returns null", async () => {
// Arrange
keyService.orgKeys$.mockReturnValue(of(null));
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow();
});
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
// Act
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
newMasterPassword,
kdfConfig,
salt,
);
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
newMasterPassword,
kdfConfig,
salt,
userKey,
);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
orgId,
orgUserId,
request,
);
});
it("should call the API method to reset the user's master password", async () => {
// Act
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
orgId,
orgUserId,
request,
);
});
});
describe("getPublicKeys", () => {
it("should return public keys for organizations that have reset password enrolled", async () => {
const result = await sut.getPublicKeys("userId" as UserId);

View File

@@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
private organizationApiService: OrganizationApiServiceAbstraction,
private i18nService: I18nService,
private accountService: AccountService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private configService: ConfigService,
) {}
/**
@@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
? new PBKDF2KdfConfig(response.kdfIterations)
: new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism);
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
// Create authentication and unlock data
const authenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
newMasterPassword,
kdfConfig,
salt,
);
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
newMasterPassword,
kdfConfig,
salt,
existingUserKey,
);
// Create request
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
// Change user's password
await this.organizationUserApiService.putOrganizationUserResetPassword(
orgId,
orgUserId,
request,
);
return; // EARLY RETURN for flagged code
}
// Create new master key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(
newMasterPassword,

View File

@@ -2,7 +2,7 @@
<bit-dialog [disablePadding]="!loading" dialogSize="large">
<span bitDialogTitle>
<ng-container *ngIf="editMode">
{{ "editCollection" | i18n }}
{{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
collection.name
}}</span>

View File

@@ -1,7 +1,9 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BitwardenSubscription } from "@bitwarden/subscription";
import {
@@ -53,4 +55,27 @@ export class AccountBillingClient {
const path = `${this.endpoint}/subscription/storage`;
await this.apiService.send("PUT", path, { additionalStorageGb }, true, false);
};
upgradePremiumToOrganization = async (
organizationName: string,
organizationKey: string,
planTier: ProductTierType,
cadence: SubscriptionCadence,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> => {
const path = `${this.endpoint}/upgrade`;
await this.apiService.send(
"POST",
path,
{
organizationName,
key: organizationKey,
targetProductTierType: planTier,
cadence,
billingAddress,
},
true,
false,
);
};
}

View File

@@ -1,4 +1,4 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";
export * from "./tax.client";
export * from "./preview-invoice.client";
export * from "./account-billing.client";

View File

@@ -1,6 +1,7 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
@@ -16,6 +17,24 @@ class TaxAmountResponse extends BaseResponse implements TaxAmounts {
}
}
export class ProrationPreviewResponse extends BaseResponse {
tax: number;
total: number;
credit: number;
newPlanProratedMonths: number;
newPlanProratedAmount: number;
constructor(response: any) {
super(response);
this.tax = this.getResponseProperty("Tax");
this.total = this.getResponseProperty("Total");
this.credit = this.getResponseProperty("Credit");
this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths");
this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount");
}
}
export type OrganizationSubscriptionPlan = {
tier: "families" | "teams" | "enterprise";
cadence: "annually" | "monthly";
@@ -51,7 +70,7 @@ export interface TaxAmounts {
}
@Injectable()
export class TaxClient {
export class PreviewInvoiceClient {
constructor(private apiService: ApiService) {}
previewTaxForOrganizationSubscriptionPurchase = async (
@@ -60,7 +79,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
"/billing/tax/organizations/subscriptions/purchase",
"/billing/preview-invoice/organizations/subscriptions/purchase",
{
purchase,
billingAddress,
@@ -82,7 +101,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/plan-change`,
`/billing/preview-invoice/organizations/${organizationId}/subscription/plan-change`,
{
plan,
billingAddress,
@@ -100,7 +119,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/organizations/${organizationId}/subscription/update`,
`/billing/preview-invoice/organizations/${organizationId}/subscription/update`,
{
update,
},
@@ -117,7 +136,7 @@ export class TaxClient {
): Promise<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/premium/subscriptions/purchase`,
`/billing/preview-invoice/premium/subscriptions/purchase`,
{
additionalStorage,
billingAddress,
@@ -128,4 +147,22 @@ export class TaxClient {
return new TaxAmountResponse(json);
};
previewProrationForPremiumUpgrade = async (
planTier: ProductTierType,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<ProrationPreviewResponse> => {
const prorationResponse = await this.apiService.send(
"POST",
`/billing/preview-invoice/premium/subscriptions/upgrade`,
{
targetProductTierType: planTier,
billingAddress,
},
true,
true,
);
return new ProrationPreviewResponse(prorationResponse);
};
}

View File

@@ -15,7 +15,7 @@ import {
DialogService,
} from "@bitwarden/components";
import { AccountBillingClient, TaxClient } from "../../../clients";
import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
@@ -74,7 +74,7 @@ export type UnifiedUpgradeDialogParams = {
UpgradePaymentComponent,
BillingServicesModule,
],
providers: [UpgradePaymentService, AccountBillingClient, TaxClient],
providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient],
templateUrl: "./unified-upgrade-dialog.component.html",
})
export class UnifiedUpgradeDialogComponent implements OnInit {

View File

@@ -21,7 +21,7 @@ import {
AccountBillingClient,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
PreviewInvoiceClient,
} from "../../../../clients";
import {
BillingAddress,
@@ -35,7 +35,7 @@ import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
describe("UpgradePaymentService", () => {
const mockOrganizationBillingService = mock<OrganizationBillingServiceAbstraction>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockTaxClient = mock<TaxClient>();
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
const mockLogService = mock<LogService>();
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
@@ -112,7 +112,7 @@ describe("UpgradePaymentService", () => {
beforeEach(() => {
mockReset(mockOrganizationBillingService);
mockReset(mockAccountBillingClient);
mockReset(mockTaxClient);
mockReset(mockPreviewInvoiceClient);
mockReset(mockLogService);
mockReset(mockOrganizationService);
mockReset(mockAccountService);
@@ -133,7 +133,7 @@ describe("UpgradePaymentService", () => {
useValue: mockOrganizationBillingService,
},
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: TaxClient, useValue: mockTaxClient },
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
{ provide: LogService, useValue: mockLogService },
{ provide: SyncService, useValue: mockSyncService },
{ provide: OrganizationService, useValue: mockOrganizationService },
@@ -183,7 +183,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -236,7 +236,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -271,7 +271,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -307,7 +307,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -333,7 +333,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -389,7 +389,7 @@ describe("UpgradePaymentService", () => {
const service = new UpgradePaymentService(
mockOrganizationBillingService,
mockAccountBillingClient,
mockTaxClient,
mockPreviewInvoiceClient,
mockLogService,
mockSyncService,
mockOrganizationService,
@@ -412,17 +412,18 @@ describe("UpgradePaymentService", () => {
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 2.5;
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse);
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(
mockResponse,
);
// Act
const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(2.5);
expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith(
0,
mockBillingAddress,
);
expect(
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase,
).toHaveBeenCalledWith(0, mockBillingAddress);
});
it("should calculate tax for families plan", async () => {
@@ -430,14 +431,18 @@ describe("UpgradePaymentService", () => {
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 5.0;
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse);
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(
mockResponse,
);
// Act
const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(5.0);
expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith(
expect(
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase,
).toHaveBeenCalledWith(
{
cadence: "annually",
tier: "families",
@@ -454,7 +459,7 @@ describe("UpgradePaymentService", () => {
it("should throw and log error if personal tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
@@ -466,7 +471,9 @@ describe("UpgradePaymentService", () => {
it("should throw and log error if organization tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error);
mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(
error,
);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress),

View File

@@ -26,7 +26,7 @@ import {
OrganizationSubscriptionPurchase,
SubscriberBillingClient,
TaxAmounts,
TaxClient,
PreviewInvoiceClient,
} from "../../../../clients";
import {
BillingAddress,
@@ -58,7 +58,7 @@ export class UpgradePaymentService {
constructor(
private organizationBillingService: OrganizationBillingServiceAbstraction,
private accountBillingClient: AccountBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private logService: LogService,
private syncService: SyncService,
private organizationService: OrganizationService,
@@ -101,7 +101,7 @@ export class UpgradePaymentService {
const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
let taxClientCall: Promise<TaxAmounts> | null = null;
let previewInvoiceClientCall: Promise<TaxAmounts> | null = null;
if (isFamiliesPlan) {
// Currently, only Families plan is supported for organization plans
@@ -111,22 +111,26 @@ export class UpgradePaymentService {
passwordManager: { seats: 1, additionalStorage: 0, sponsored: false },
};
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
request,
previewInvoiceClientCall =
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
request,
billingAddress,
);
}
if (isPremiumPlan) {
previewInvoiceClientCall = this.previewInvoiceClient.previewTaxForPremiumSubscriptionPurchase(
0,
billingAddress,
);
}
if (isPremiumPlan) {
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
}
if (taxClientCall === null) {
throw new Error("Tax client call is not defined");
if (previewInvoiceClientCall === null) {
throw new Error("Preview client call is not defined");
}
try {
const preview = await taxClientCall;
const preview = await previewInvoiceClientCall;
return preview.tax;
} catch (error) {
this.logService.error("Tax calculation failed:", error);

View File

@@ -50,7 +50,7 @@ import { KeyService } from "@bitwarden/key-management";
import {
OrganizationSubscriptionPlan,
SubscriberBillingClient,
TaxClient,
PreviewInvoiceClient,
} from "@bitwarden/web-vault/app/billing/clients";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import {
@@ -117,7 +117,7 @@ interface OnSuccessArgs {
EnterBillingAddressComponent,
CardComponent,
],
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -248,7 +248,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private organizationWarningsService: OrganizationWarningsService,
private configService: ConfigService,
) {}
@@ -1068,11 +1068,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress)
: this.billingAddress;
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organizationId,
getPlanFromLegacyEnum(this.selectedPlan.type),
billingAddress,
);
const taxAmounts =
await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organizationId,
getPlanFromLegacyEnum(this.selectedPlan.type),
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}

View File

@@ -52,8 +52,8 @@ import { KeyService } from "@bitwarden/key-management";
import {
OrganizationSubscriptionPlan,
OrganizationSubscriptionPurchase,
PreviewInvoiceClient,
SubscriberBillingClient,
TaxClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
@@ -87,7 +87,7 @@ const Allowed2020PlansForLegacyProviders = [
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class OrganizationPlansComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -219,7 +219,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private configService: ConfigService,
) {
this.selfHosted = this.platformUtilsService.isSelfHost();
@@ -793,11 +793,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// by comparing tax on base+storage vs tax on base only
//TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585
const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(0, false),
billingAddress,
),
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false),
billingAddress,
),
@@ -806,10 +806,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// Tax on storage = Tax on (base + storage) - Tax on (base only)
this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax;
} else {
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview),
billingAddress,
);
const taxAmounts =
await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
this.buildTaxPreviewRequest(
this.formGroup.value.additionalStorage,
sponsoredForTaxPreview,
),
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}

View File

@@ -34,7 +34,10 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
SubscriberBillingClient,
PreviewInvoiceClient,
} from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
@@ -73,7 +76,7 @@ interface OnSuccessArgs {
selector: "app-trial-payment-dialog",
templateUrl: "./trial-payment-dialog.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
providers: [SubscriberBillingClient, PreviewInvoiceClient],
})
export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -118,7 +121,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
) {
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
}
@@ -300,7 +303,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
const tier = getTierFromLegacyEnum(this.organization);
if (tier && cadence) {
const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange(
const costs = await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange(
this.organization.id,
{
tier,

View File

@@ -15,7 +15,7 @@ import {
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
EnterBillingAddressComponent,
@@ -41,7 +41,7 @@ export interface OrganizationCreatedEvent {
selector: "app-trial-billing-step",
templateUrl: "./trial-billing-step.component.html",
imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule],
providers: [TaxClient, TrialBillingStepService],
providers: [PreviewInvoiceClient, TrialBillingStepService],
})
export class TrialBillingStepComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals

View File

@@ -12,7 +12,7 @@ import {
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients";
import {
BillingAddressControls,
getBillingAddressFromControls,
@@ -63,7 +63,7 @@ export class TrialBillingStepService {
private accountService: AccountService,
private apiService: ApiService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private taxClient: TaxClient,
private previewInvoiceClient: PreviewInvoiceClient,
private configService: ConfigService,
) {}
@@ -129,7 +129,7 @@ export class TrialBillingStepService {
total: number;
}> => {
const billingAddress = getBillingAddressFromControls(billingAddressControls);
return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
return await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase(
{
tier,
cadence,

View File

@@ -54,7 +54,7 @@
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
<th bitCell class="tw-text-right" bitSortable="scoreKey" default="desc">
{{ "weakness" | i18n }}
</th>
</ng-container>

View File

@@ -1,6 +1,6 @@
<bit-dialog dialogSize="large" disablePadding="false" background="alt">
<ng-container bitDialogTitle>
<span>{{ dialogTitle() | i18n }}</span>
<span>{{ dialogTitle | i18n }}</span>
</ng-container>
<ng-container bitDialogContent>
<div
@@ -17,7 +17,13 @@
</h3>
<p bitTypography="body1" class="tw-mb-6 tw-max-w-sm">
{{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }}
@let translationKey =
send.authType === AuthType.Email
? "sendCreatedDescriptionEmail"
: send.authType === AuthType.Password
? "sendCreatedDescriptionPassword"
: "sendCreatedDescriptionV2";
{{ translationKey | i18n: formattedExpirationTime }}
</p>
<bit-form-field class="tw-w-full tw-max-w-sm tw-mb-4">

View File

@@ -0,0 +1,162 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
DIALOG_DATA,
DialogModule,
I18nMockService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component";
describe("SendSuccessDrawerDialogComponent", () => {
let fixture: ComponentFixture<SendSuccessDrawerDialogComponent>;
let component: SendSuccessDrawerDialogComponent;
let environmentService: MockProxy<EnvironmentService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let toastService: MockProxy<ToastService>;
let sendView: SendView;
// Translation Keys
const newTextSend = "New Text Send";
const newFileSend = "New File Send";
const oneHour = "1 hour";
const oneDay = "1 day";
const sendCreatedSuccessfully = "Send has been created successfully";
const sendCreatedDescriptionV2 = "Send ready to share with anyone";
const sendCreatedDescriptionEmail = "Email-verified Send ready to share";
const sendCreatedDescriptionPassword = "Password-protected Send ready to share";
beforeEach(async () => {
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();
toastService = mock<ToastService>();
sendView = {
id: "test-send-id",
authType: AuthType.None,
deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
type: SendType.Text,
accessId: "abc",
urlB64Key: "123",
} as SendView;
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
});
await TestBed.configureTestingModule({
imports: [SharedModule, DialogModule, TypographyModule],
providers: [
{
provide: DIALOG_DATA,
useValue: sendView,
},
{ provide: EnvironmentService, useValue: environmentService },
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
newTextSend,
newFileSend,
sendCreatedSuccessfully,
sendCreatedDescriptionEmail,
sendCreatedDescriptionPassword,
sendCreatedDescriptionV2,
sendLink: "Send link",
copyLink: "Copy Send Link",
close: "Close",
oneHour,
durationTimeHours: (hours) => `${hours} hours`,
oneDay,
days: (days) => `${days} days`,
loading: "loading",
});
},
},
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should have the correct title for text Sends", () => {
sendView.type = SendType.Text;
fixture.detectChanges();
expect(component.dialogTitle).toBe("newTextSend");
});
it("should have the correct title for file Sends", () => {
fixture.componentInstance.send.type = SendType.File;
fixture.detectChanges();
expect(component.dialogTitle).toBe("newFileSend");
});
it("should show the correct message for Sends with an expiration time of one hour from now", () => {
sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(oneHour);
});
it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => {
const numHours = 8;
sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(`${numHours} hours`);
});
it("should have the correct title for Sends with an expiration time of one day from now", () => {
sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(oneDay);
});
it("should have the correct title for Sends with an expiration time of multiple days from now", () => {
const numDays = 3;
sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000);
fixture.detectChanges();
expect(component.formattedExpirationTime).toBe(`${numDays} days`);
});
it("should show the correct message for successfully-created Sends with no authentication", () => {
sendView.authType = AuthType.None;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2);
});
it("should show the correct message for successfully-created Sends with password authentication", () => {
sendView.authType = AuthType.Password;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword);
});
it("should show the correct message for successfully-created Sends with email authentication", () => {
sendView.authType = AuthType.Email;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail);
});
});

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core";
import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ActiveSendIcon } from "@bitwarden/assets/svg";
@@ -6,6 +6,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendSuccessDrawerDialogComponent {
readonly AuthType = AuthType;
readonly sendLink = signal<string>("");
activeSendIcon = ActiveSendIcon;
// Computed property to get the dialog title based on send type
readonly dialogTitle = computed(() => {
get dialogTitle(): string {
return this.send.type === SendType.Text ? "newTextSend" : "newFileSend";
});
}
constructor(
@Inject(DIALOG_DATA) public send: SendView,

View File

@@ -593,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsWereSentToArchive"),
message: this.i18nService.t("itemWasSentToArchive"),
});
} catch {
this.toastService.showToast({

View File

@@ -157,7 +157,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
if (!this.archiveEnabled()) {
if (!this.archiveEnabled() || this.viewingOrgVault) {
return false;
}

View File

@@ -4,7 +4,7 @@
[disabled]="disabled"
[style.color]="textColor"
[style.background-color]="color"
appA11yTitle="{{ organizationName }}"
appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}"
routerLink
[queryParams]="{ organizationId: organizationIdLink }"
queryParamsHandling="merge"

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html class="theme_light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1010" />
<meta name="theme-color" content="#175DDC" />
<title>Bitwarden Web vault</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../images/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="../../images/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../images/icons/favicon-16x16.png" />
<link rel="mask-icon" href="../../images/icons/safari-pinned-tab.svg" color="#175DDC" />
<link rel="manifest" href="../../manifest.json" />
</head>
<body class="layout_frontend">
<div class="tw-p-8 tw-flex">
<img class="new-logo-themed" alt="Bitwarden" />
<div class="spinner-container tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
title="Loading"
aria-hidden="true"
></i>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
/**
* ONLY FOR SELF-HOSTED SETUPS
* Redirects the user to the SSO cookie vendor endpoint when the window finishes loading.
*
* This script listens for the window's load event and automatically redirects the browser
* to the `api/sso-cookie-vendor` path on the current origin. This is used as part
* of an authentication flow where cookies need to be set or validated through a vendor endpoint.
*/
window.addEventListener("DOMContentLoaded", () => {
const origin = window.location.origin;
let apiURL = `${window.location.origin}/api/sso-cookie-vendor`;
// Override for local testing
if (origin.startsWith("https://localhost")) {
apiURL = "http://localhost:4000/sso-cookie-vendor";
}
window.location.href = apiURL;
});

View File

@@ -32,6 +32,9 @@
}
}
},
"criticalBadge":{
"message": "Critical"
},
"accessIntelligence": {
"message": "Access Intelligence"
},
@@ -3802,6 +3805,9 @@
"editCollection": {
"message": "Edit collection"
},
"viewCollection": {
"message": "View collection"
},
"collectionInfo": {
"message": "Collection info"
},
@@ -5669,6 +5675,26 @@
}
}
},
"sendCreatedDescriptionPassword": {
"message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "7 days, 1 hour, 1 day"
}
}
},
"sendCreatedDescriptionEmail": {
"message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "7 days, 1 hour, 1 day"
}
}
},
"durationTimeHours": {
"message": "$HOURS$ hours",
"placeholders": {
@@ -12739,6 +12765,15 @@
"whenYouRemoveStorage": {
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
},
"ownerBadgeA11yDescription":{
"message": "Owner, $OWNER$, show all items owned by $OWNER$",
"placeholders":{
"owner": {
"content": "$1",
"example": "My Org Name"
}
}
},
"youHavePremium": {
"message": "You have Premium"
},

View File

@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"],
"include": ["src/connectors/*.ts"]
"include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"]
}

View File

@@ -4,5 +4,10 @@
"strictTemplates": true
},
"files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"],
"include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"]
"include": [
"src/connectors/*.ts",
"src/connectors/platform/*.ts",
"src/**/*.stories.ts",
"src/**/*.spec.ts"
]
}

View File

@@ -166,6 +166,11 @@ module.exports.buildConfig = function buildConfig(params) {
filename: "duo-redirect-connector.html",
chunks: ["connectors/duo-redirect", "styles"],
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"),
filename: "proxy-cookie-redirect-connector.html",
chunks: ["connectors/platform/proxy-cookie-redirect", "styles"],
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src/404.html"),
filename: "404.html",
@@ -403,6 +408,10 @@ module.exports.buildConfig = function buildConfig(params) {
"connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"),
"connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"),
"connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"),
"connectors/platform/proxy-cookie-redirect": path.resolve(
__dirname,
"src/connectors/platform/proxy-cookie-redirect.ts",
),
styles: [
path.resolve(__dirname, "src/scss/styles.scss"),
path.resolve(__dirname, "src/scss/tailwind.css"),

View File

@@ -19,6 +19,7 @@
[ngModel]="selectedFilter()"
(ngModelChange)="setFilterApplicationsByStatus($event)"
fullWidth="false"
class="tw-min-w-48"
></bit-chip-select>
<button
@@ -37,7 +38,6 @@
<app-table-row-scrollable-m11
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls()"
[openApplication]="drawerDetails.invokerId || ''"

View File

@@ -12,32 +12,18 @@
<th bitSortable="memberCount" bitCell tabindex="0">{{ "totalMembers" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
@if (showRowCheckBox) {
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
appStopProp
>
@if (!row.isMarkedAsCritical) {
<input
bitCheckbox
type="checkbox"
[checked]="selectedUrls.has(row.applicationName)"
(change)="checkboxChange(row.applicationName, $event)"
/>
}
@if (row.isMarkedAsCritical) {
<i class="bwi bwi-star-f"></i>
}
</td>
}
@if (!showRowCheckBox) {
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
@if (row.isMarkedAsCritical) {
<i class="bwi bwi-star-f"></i>
}
</td>
}
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
appStopProp
>
<input
bitCheckbox
type="checkbox"
[checked]="selectedUrls.has(row.applicationName)"
(change)="checkboxChange(row.applicationName, $event)"
/>
</td>
<td
bitCell
class="tw-cursor-pointer"
@@ -54,7 +40,7 @@
}
</td>
<td
class="tw-cursor-pointer"
class="tw-cursor-pointer tw-align-middle"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
(click)="showAppAtRiskMembers(row.applicationName)"
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
@@ -63,7 +49,14 @@
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
>
<span>{{ row.applicationName }}</span>
<div class="tw-flex tw-items-center tw-gap-2">
<div class="tw-max-w-md tw-truncate" [title]="row.applicationName">
{{ row.applicationName }}
</div>
@if (row.isMarkedAsCritical) {
<span bitBadge>{{ "criticalBadge" | i18n }}</span>
}
</div>
</td>
<td
bitCell

View File

@@ -28,9 +28,6 @@ export class AppTableRowScrollableM11Component {
@Input() showRowMenuForCriticalApps: boolean = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() showRowCheckBox: boolean = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selectedUrls: Set<string> = new Set<string>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals

View File

@@ -9,5 +9,5 @@
"../../bitwarden_license/bit-web/src/main.ts"
],
"include": ["../../apps/web/src/connectors/*.ts"]
"include": ["../../apps/web/src/connectors/*.ts", "../../apps/web/src/connectors/platform/*.ts"]
}

View File

@@ -11,6 +11,7 @@
],
"include": [
"../../apps/web/src/connectors/*.ts",
"../../apps/web/src/connectors/platform/*.ts",
"../../apps/web/src/**/*.stories.ts",
"../../apps/web/src/**/*.spec.ts",
"src/**/*.stories.ts",

View File

@@ -21,6 +21,9 @@ export class VaultProfileService {
* Returns the creation date of the profile.
* Note: `Date`s are mutable in JS, creating a new
* instance is important to avoid unwanted changes.
*
* @deprecated use `creationDate` directly from the `AccountService.activeAccount$` instead,
* PM-31409 will replace all usages of this service.
*/
async getProfileCreationDate(userId: string): Promise<Date> {
if (this.profileCreatedDate && userId === this.userId) {

View File

@@ -22,6 +22,7 @@ export enum FeatureFlag {
SafariAccountSwitching = "pm-5594-safari-account-switching",
/* Autofill */
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
@@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,

View File

@@ -75,7 +75,7 @@ export class Fido2CredentialExport {
domain.userDisplayName =
req.userDisplayName != null ? new EncString(req.userDisplayName) : null;
domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null;
domain.creationDate = req.creationDate;
domain.creationDate = req.creationDate != null ? new Date(req.creationDate) : null;
return domain;
}
@@ -111,10 +111,12 @@ export class Fido2CredentialExport {
this.rpId = safeGetString(o.rpId);
this.userHandle = safeGetString(o.userHandle);
this.userName = safeGetString(o.userName);
this.counter = safeGetString(String(o.counter));
this.counter = safeGetString(o instanceof Fido2CredentialView ? String(o.counter) : o.counter);
this.rpName = safeGetString(o.rpName);
this.userDisplayName = safeGetString(o.userDisplayName);
this.discoverable = safeGetString(String(o.discoverable));
this.discoverable = safeGetString(
o instanceof Fido2CredentialView ? String(o.discoverable) : o.discoverable,
);
this.creationDate = o.creationDate;
}
}

View File

@@ -39,7 +39,11 @@ export class LoginExport {
domain.username = req.username != null ? new EncString(req.username) : null;
domain.password = req.password != null ? new EncString(req.password) : null;
domain.totp = req.totp != null ? new EncString(req.totp) : null;
// Fido2credentials are currently not supported for exports.
if (req.fido2Credentials != null) {
domain.fido2Credentials = req.fido2Credentials.map((f2) =>
Fido2CredentialExport.toDomain(f2),
);
}
return domain;
}

View File

@@ -16,6 +16,11 @@ export abstract class VaultSettingsService {
* An observable monitoring the state of the show identities on the current tab.
*/
abstract showIdentitiesCurrentTab$: Observable<boolean>;
/**
* An observable monitoring the state of the click items on the Vault view
* for Autofill suggestions.
*/
abstract clickItemsToAutofillVaultView$: Observable<boolean>;
/**
* Saves the enable passkeys setting to disk.
@@ -32,4 +37,10 @@ export abstract class VaultSettingsService {
* @param value The new value for the show identities on tab page setting.
*/
abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>;
/**
* Saves the click items on vault View for Autofill suggestions to disk.
* @param value The new value for the click items on vault View for
* Autofill suggestions setting.
*/
abstract setClickItemsToAutofillVaultView(value: boolean): Promise<void>;
}

View File

@@ -25,3 +25,12 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition<boolean>(
clearOn: [], // do not clear user settings
},
);
export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition<boolean>(
VAULT_SETTINGS_DISK,
"clickItemsToAutofillOnVaultView",
{
deserializer: (obj) => obj,
clearOn: [], // do not clear user settings
},
);

View File

@@ -1,4 +1,4 @@
import { Observable, combineLatest, map } from "rxjs";
import { Observable, combineLatest, map, shareReplay } from "rxjs";
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
@@ -7,6 +7,7 @@ import {
SHOW_CARDS_CURRENT_TAB,
SHOW_IDENTITIES_CURRENT_TAB,
USER_ENABLE_PASSKEYS,
CLICK_ITEMS_AUTOFILL_VAULT_VIEW,
} from "../key-state/vault-settings.state";
import { RestrictedItemTypesService } from "../restricted-item-types.service";
@@ -48,6 +49,17 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
readonly showIdentitiesCurrentTab$: Observable<boolean> =
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
private clickItemsToAutofillVaultViewState: ActiveUserState<boolean> =
this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
/**
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
*/
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
this.clickItemsToAutofillVaultViewState.state$.pipe(
map((x) => x ?? false),
shareReplay({ bufferSize: 1, refCount: false }),
);
constructor(
private stateProvider: StateProvider,
private restrictedItemTypesService: RestrictedItemTypesService,
@@ -67,6 +79,13 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
await this.showIdentitiesCurrentTabState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
*/
async setClickItemsToAutofillVaultView(value: boolean): Promise<void> {
await this.clickItemsToAutofillVaultViewState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
*/

View File

@@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => {
expect(result.collections[0].name).toBe("collection1/collection2");
expect(result.collections[1].name).toBe("collection1");
});
it("should parse archived items correctly", async () => {
const archivedDate = "2025-01-15T10:30:00.000Z";
const data =
`name,type,archivedDate,login_uri,login_username,login_password` +
`\nArchived Login,login,${archivedDate},https://example.com,user,pass`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
const cipher = result.ciphers[0];
expect(cipher.name).toBe("Archived Login");
expect(cipher.archivedDate).toBeDefined();
expect(cipher.archivedDate.toISOString()).toBe(archivedDate);
});
it("should handle missing archivedDate gracefully", async () => {
const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
expect(result.ciphers[0].archivedDate).toBeUndefined();
});
});

View File

@@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer {
cipher.reprompt = CipherRepromptType.None;
}
if (!this.isNullOrWhitespace(value.archivedDate)) {
try {
cipher.archivedDate = new Date(value.archivedDate);
} catch (e) {
// eslint-disable-next-line
console.error("Unable to parse archivedDate value", e);
}
}
if (!this.isNullOrWhitespace(value.fields)) {
const fields = this.splitNewLine(value.fields);
for (let i = 0; i < fields.length; i++) {

View File

@@ -0,0 +1,87 @@
import { ButtercupCsvImporter } from "./buttercup-csv-importer";
import {
buttercupCsvTestData,
buttercupCsvWithCustomFieldsTestData,
buttercupCsvWithNoteTestData,
buttercupCsvWithSubfoldersTestData,
buttercupCsvWithUrlFieldTestData,
} from "./spec-data/buttercup-csv/testdata.csv";
describe("Buttercup CSV Importer", () => {
let importer: ButtercupCsvImporter;
beforeEach(() => {
importer = new ButtercupCsvImporter();
});
describe("given basic login data", () => {
it("should parse login data when provided valid CSV", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(2);
const cipher = result.ciphers[0];
expect(cipher.name).toEqual("Test Entry");
expect(cipher.login.username).toEqual("testuser");
expect(cipher.login.password).toEqual("testpass123");
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://example.com");
});
it("should assign entries to folders based on group_name", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.folders.length).toBe(1);
expect(result.folders[0].name).toEqual("General");
expect(result.folderRelationships.length).toBe(2);
});
});
describe("given URL field variations", () => {
it("should handle lowercase url field", async () => {
const result = await importer.parse(buttercupCsvWithUrlFieldTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com");
});
});
describe("given note field", () => {
it("should map note field to notes", async () => {
const result = await importer.parse(buttercupCsvWithNoteTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.notes).toEqual("This is a note");
});
});
describe("given custom fields", () => {
it("should import custom fields and exclude official props", async () => {
const result = await importer.parse(buttercupCsvWithCustomFieldsTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.fields.length).toBe(2);
expect(cipher.fields[0].name).toEqual("custom_field");
expect(cipher.fields[0].value).toEqual("custom value");
expect(cipher.fields[1].name).toEqual("another_field");
expect(cipher.fields[1].value).toEqual("another value");
});
});
describe("given subfolders", () => {
it("should create nested folder structure", async () => {
const result = await importer.parse(buttercupCsvWithSubfoldersTestData);
expect(result.success).toBe(true);
const folderNames = result.folders.map((f) => f.name);
expect(folderNames).toContain("Work/Projects");
expect(folderNames).toContain("Work");
expect(folderNames).toContain("Personal/Finance");
expect(folderNames).toContain("Personal");
});
});
});

View File

@@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result";
import { BaseImporter } from "./base-importer";
import { Importer } from "./importer";
const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"];
const OfficialProps = [
"!group_id",
"!group_name",
"!type",
"title",
"username",
"password",
"URL",
"url",
"note",
"id",
];
export class ButtercupCsvImporter extends BaseImporter implements Importer {
parse(data: string): Promise<ImportResult> {
@@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer {
cipher.name = this.getValueOrDefault(value.title, "--");
cipher.login.username = this.getValueOrDefault(value.username);
cipher.login.password = this.getValueOrDefault(value.password);
cipher.login.uris = this.makeUriArray(value.URL);
let processingCustomFields = false;
// Handle URL field (case-insensitive)
const urlValue = value.URL || value.url || value.Url;
cipher.login.uris = this.makeUriArray(urlValue);
// Handle note field (case-insensitive)
const noteValue = value.note || value.Note || value.notes || value.Notes;
if (noteValue) {
cipher.notes = noteValue;
}
// Process custom fields, excluding official props (case-insensitive)
for (const prop in value) {
// eslint-disable-next-line
if (value.hasOwnProperty(prop)) {
if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) {
processingCustomFields = true;
}
if (processingCustomFields) {
const lowerProp = prop.toLowerCase();
const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp);
if (!isOfficialProp && value[prop]) {
this.processKvp(cipher, prop, value[prop]);
}
}

View File

@@ -0,0 +1,16 @@
export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id
1,General,Test Entry,testuser,testpass123,https://example.com,entry1
1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`;
export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id
1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`;
export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id
1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`;
export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id
1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`;
export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id
1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1
2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`;

View File

@@ -59,6 +59,7 @@ export class BaseVaultExportService {
cipher.notes = c.notes;
cipher.fields = null;
cipher.reprompt = c.reprompt;
cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null;
// Login props
cipher.login_uri = null;
cipher.login_username = null;

View File

@@ -12,6 +12,7 @@ export type BitwardenCsvExportType = {
login_password: string;
login_totp: string;
favorite: number | null;
archivedDate: string | null;
};
export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & {

View File

@@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
const orgNodes: TreeNode<OrganizationFilter>[] = [];
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
orgCopy.icon = "bwi-business";
if (
org?.productTierType === ProductTierType.Free ||
org?.productTierType === ProductTierType.Families
) {
orgCopy.icon = "bwi-family";
} else {
orgCopy.icon = "bwi-business";
}
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);
orgNodes.push(node);
});