mirror of
https://github.com/bitwarden/browser
synced 2026-02-08 20:50:28 +00:00
Merge remote-tracking branch 'origin/main' into PM-29033
This commit is contained in:
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -154,6 +154,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
|
||||
|
||||
26
.github/workflows/build-desktop.yml
vendored
26
.github/workflows/build-desktop.yml
vendored
@@ -236,7 +236,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -827,7 +827,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -1032,14 +1032,14 @@ jobs:
|
||||
|
||||
- name: Cache Build
|
||||
id: build-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/desktop/build
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-build
|
||||
|
||||
- name: Cache Safari
|
||||
id: safari-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/browser/dist/Safari
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
|
||||
@@ -1185,7 +1185,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -1272,14 +1272,14 @@ jobs:
|
||||
|
||||
- name: Get Build Cache
|
||||
id: build-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/desktop/build
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-build
|
||||
|
||||
- name: Setup Safari Cache
|
||||
id: safari-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/browser/dist/Safari
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
|
||||
@@ -1409,7 +1409,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -1547,14 +1547,14 @@ jobs:
|
||||
|
||||
- name: Get Build Cache
|
||||
id: build-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/desktop/build
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-build
|
||||
|
||||
- name: Setup Safari Cache
|
||||
id: safari-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: apps/browser/dist/Safari
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
|
||||
@@ -1692,7 +1692,7 @@ jobs:
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
|
||||
18
.github/workflows/build-web.yml
vendored
18
.github/workflows/build-web.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Cache NPM
|
||||
id: npm-cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: "~/.npm"
|
||||
key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,212 @@ 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,
|
||||
] 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 +1176,7 @@ export default class NotificationBackground {
|
||||
});
|
||||
}
|
||||
|
||||
// @TODO this needs the whole input record, and not just newPassword
|
||||
private async pushChangePasswordToQueue(
|
||||
cipherIds: CipherView["id"][],
|
||||
loginDomain: string,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => {
|
||||
});
|
||||
|
||||
it("passes the submit button to the cipher attachments component", () => {
|
||||
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
|
||||
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0]
|
||||
.componentInstance;
|
||||
|
||||
expect(cipherAttachment.submitBtn()).toEqual(submitBtn);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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$;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
10
apps/desktop/desktop_native/Cargo.lock
generated
10
apps/desktop/desktop_native/Cargo.lock
generated
@@ -918,6 +918,7 @@ dependencies = [
|
||||
"oo7",
|
||||
"pin-project",
|
||||
"rand 0.9.2",
|
||||
"rsa",
|
||||
"scopeguard",
|
||||
"secmem-proc",
|
||||
"security-framework",
|
||||
@@ -2115,10 +2116,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
@@ -2805,9 +2807,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
|
||||
@@ -50,7 +50,7 @@ oo7 = "=0.5.0"
|
||||
pin-project = "=1.1.10"
|
||||
pkcs8 = "=0.10.2"
|
||||
rand = "=0.9.2"
|
||||
rsa = "=0.9.10"
|
||||
rsa = "=0.9.6"
|
||||
russh-cryptovec = "=0.7.3"
|
||||
scopeguard = "=1.2.0"
|
||||
secmem-proc = "=0.3.7"
|
||||
|
||||
@@ -31,6 +31,7 @@ futures = { workspace = true }
|
||||
interprocess = { workspace = true, features = ["tokio"] }
|
||||
memsec = { workspace = true, features = ["alloc_ext"] }
|
||||
rand = { workspace = true }
|
||||
rsa = "=0.9.6"
|
||||
sha2 = { workspace = true }
|
||||
ssh-key = { workspace = true, features = [
|
||||
"encryption",
|
||||
@@ -85,5 +86,8 @@ windows = { workspace = true, features = [
|
||||
], optional = true }
|
||||
windows-future = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Err
|
||||
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ssh_key::Signature;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Test Ed25519 key (unencrypted OpenSSH format)
|
||||
const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo
|
||||
wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g
|
||||
AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G
|
||||
nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ==
|
||||
-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
// Test RSA 2048-bit key (unencrypted OpenSSH format)
|
||||
const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
|
||||
NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o
|
||||
slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0
|
||||
MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17
|
||||
34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ
|
||||
FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ
|
||||
uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3
|
||||
YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15
|
||||
kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J
|
||||
XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE
|
||||
9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs
|
||||
38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB
|
||||
fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z
|
||||
u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf
|
||||
Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL
|
||||
VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd
|
||||
A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN
|
||||
n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX
|
||||
YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w
|
||||
TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD
|
||||
Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+
|
||||
jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7
|
||||
Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY
|
||||
fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3
|
||||
CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw==
|
||||
-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
fn create_test_agent() -> (
|
||||
BitwardenDesktopAgent,
|
||||
tokio::sync::mpsc::Receiver<SshAgentUIRequest>,
|
||||
tokio::sync::broadcast::Sender<(u32, bool)>,
|
||||
) {
|
||||
let (request_tx, request_rx) = tokio::sync::mpsc::channel::<SshAgentUIRequest>(16);
|
||||
let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16);
|
||||
let response_rx = Arc::new(Mutex::new(response_rx));
|
||||
|
||||
let agent = BitwardenDesktopAgent::new(request_tx, response_rx);
|
||||
(agent, request_rx, response_tx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_sign_with_ed25519_key() {
|
||||
let (mut agent, _request_rx, _response_tx) = create_test_agent();
|
||||
agent.is_running.store(true, Ordering::Relaxed);
|
||||
|
||||
let keys = vec![(
|
||||
TEST_ED25519_KEY.to_string(),
|
||||
"ed25519-key".to_string(),
|
||||
"ed25519-uuid".to_string(),
|
||||
)];
|
||||
agent.set_keys(keys).expect("set_keys should succeed");
|
||||
|
||||
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
|
||||
assert_eq!(keystore.len(), 1);
|
||||
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
|
||||
|
||||
// Verify the key metadata
|
||||
assert_eq!(ssh_key.name, "ed25519-key");
|
||||
assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid");
|
||||
|
||||
// Verify the key can sign data
|
||||
let signing_key = ssh_key.private_key().expect("should have signing key");
|
||||
let message = b"test message for ed25519";
|
||||
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
|
||||
|
||||
// Verify signature is non-empty and has expected algorithm
|
||||
assert!(!signature.as_bytes().is_empty());
|
||||
assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_sign_with_rsa_key() {
|
||||
let (mut agent, _request_rx, _response_tx) = create_test_agent();
|
||||
agent.is_running.store(true, Ordering::Relaxed);
|
||||
|
||||
let keys = vec![(
|
||||
TEST_RSA_KEY.to_string(),
|
||||
"rsa-key".to_string(),
|
||||
"rsa-uuid".to_string(),
|
||||
)];
|
||||
agent.set_keys(keys).expect("set_keys should succeed");
|
||||
|
||||
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
|
||||
assert_eq!(keystore.len(), 1);
|
||||
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
|
||||
|
||||
// Verify the key metadata
|
||||
assert_eq!(ssh_key.name, "rsa-key");
|
||||
assert_eq!(ssh_key.cipher_uuid, "rsa-uuid");
|
||||
|
||||
// Verify the key can sign data
|
||||
let signing_key = ssh_key.private_key().expect("should have signing key");
|
||||
let message = b"test message for rsa";
|
||||
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
|
||||
|
||||
// Verify signature is non-empty and has expected algorithm
|
||||
assert!(!signature.as_bytes().is_empty());
|
||||
assert_eq!(
|
||||
signature.algorithm(),
|
||||
ssh_key::Algorithm::Rsa {
|
||||
hash: Some(ssh_key::HashAlg::Sha512)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
332
apps/desktop/desktop_native/napi/src/autofill.rs
Normal file
332
apps/desktop/desktop_native/napi/src/autofill.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/desktop/desktop_native/napi/src/autostart.rs
Normal file
9
apps/desktop/desktop_native/napi/src/autostart.rs
Normal 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:?}")))
|
||||
}
|
||||
}
|
||||
20
apps/desktop/desktop_native/napi/src/autotype.rs
Normal file
20
apps/desktop/desktop_native/napi/src/autotype.rs
Normal 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}")))
|
||||
}
|
||||
}
|
||||
100
apps/desktop/desktop_native/napi/src/biometrics.rs
Normal file
100
apps/desktop/desktop_native/napi/src/biometrics.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
apps/desktop/desktop_native/napi/src/biometrics_v2.rs
Normal file
116
apps/desktop/desktop_native/napi/src/biometrics_v2.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
116
apps/desktop/desktop_native/napi/src/chromium_importer.rs
Normal file
116
apps/desktop/desktop_native/napi/src/chromium_importer.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
15
apps/desktop/desktop_native/napi/src/clipboards.rs
Normal file
15
apps/desktop/desktop_native/napi/src/clipboards.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
106
apps/desktop/desktop_native/napi/src/ipc.rs
Normal file
106
apps/desktop/desktop_native/napi/src/ipc.rs
Normal 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
131
apps/desktop/desktop_native/napi/src/logging.rs
Normal file
131
apps/desktop/desktop_native/napi/src/logging.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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:?}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
46
apps/desktop/desktop_native/napi/src/passwords.rs
Normal file
46
apps/desktop/desktop_native/napi/src/passwords.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
26
apps/desktop/desktop_native/napi/src/powermonitors.rs
Normal file
26
apps/desktop/desktop_native/napi/src/powermonitors.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
23
apps/desktop/desktop_native/napi/src/processisolations.rs
Normal file
23
apps/desktop/desktop_native/napi/src/processisolations.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
163
apps/desktop/desktop_native/napi/src/sshagent.rs
Normal file
163
apps/desktop/desktop_native/napi/src/sshagent.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
16
apps/desktop/desktop_native/napi/src/windows_registry.rs
Normal file
16
apps/desktop/desktop_native/napi/src/windows_registry.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated 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("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
@@ -116,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => {
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId",
|
||||
resetPasswordAutoEnroll: false,
|
||||
newPassword: "Test@Password123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
};
|
||||
userId = "userId" as UserId;
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
|
||||
@@ -54,6 +54,9 @@ export class DesktopSetInitialPasswordService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
override async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
|
||||
@@ -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>
|
||||
{{ organization.name }}
|
||||
</button>
|
||||
<span *ngIf="!organization.enabled" class="tw-ml-auto">
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
}
|
||||
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
|
||||
cipherId: this.cipherId as CipherId,
|
||||
canEditCipher: this.cipher().edit,
|
||||
});
|
||||
const result = await firstValueFrom(dialogRef.closed).catch((): any => null);
|
||||
if (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated 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("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
@@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => {
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId",
|
||||
resetPasswordAutoEnroll: false,
|
||||
newPassword: "Test@Password123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
};
|
||||
userId = "userId" as UserId;
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
|
||||
@@ -56,6 +56,9 @@ export class WebSetInitialPasswordService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
override async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[ngClass]="{ 'tw-grayscale': disabled }"
|
||||
>
|
||||
<div class="tw-m-auto tw-size-20 tw-content-center">
|
||||
<bit-svg [content]="icon" aria-hidden="true"></bit-svg>
|
||||
<bit-svg [content]="icon" aria-hidden="true" class="tw-h-full"></bit-svg>
|
||||
</div>
|
||||
</div>
|
||||
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">
|
||||
|
||||
@@ -3,8 +3,7 @@ import { FormBuilder } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
emailAndOtpRequiredEmailSent,
|
||||
emailInvalid,
|
||||
emailAndOtpRequired,
|
||||
emailRequired,
|
||||
otpInvalid,
|
||||
passwordHashB64Invalid,
|
||||
@@ -161,7 +160,7 @@ export class SendAuthComponent implements OnInit {
|
||||
this.expiredAuthAttempts = 0;
|
||||
if (emailRequired(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Email);
|
||||
} else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) {
|
||||
} else if (emailAndOtpRequired(response.error)) {
|
||||
this.enterOtp.set(true);
|
||||
} else if (otpInvalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -925,6 +925,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
|
||||
cipherId: cipher.id as CipherId,
|
||||
organizationId: cipher.organizationId as OrganizationId,
|
||||
canEditCipher: cipher.edit,
|
||||
});
|
||||
|
||||
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"criticalBadge":{
|
||||
"message": "Critical"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
},
|
||||
|
||||
@@ -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 || ''"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +63,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
protected registerSdkService: RegisterSdkService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143. When you remove this, also check for any objects/methods
|
||||
* in this default service that are now un-used and can also be removed.
|
||||
*/
|
||||
async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
@@ -333,6 +337,9 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
private async makeMasterKeyEncryptedUserKey(
|
||||
masterKey: MasterKey,
|
||||
userId: UserId,
|
||||
@@ -410,6 +417,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*
|
||||
* As part of [PM-28494], adding this setting path to accommodate the changes that are
|
||||
* emerging with pm-23246-unlock-with-master-password-unlock-data.
|
||||
* Without this, immediately locking/unlocking the vault with the new password _may_ still fail
|
||||
|
||||
@@ -124,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated 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("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
@@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private keyService: KeyService,
|
||||
private logoutService: LogoutService,
|
||||
private logService: LogService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
@@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
|
||||
switch (this.userType) {
|
||||
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
|
||||
/**
|
||||
* "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration
|
||||
* "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and
|
||||
* passed through via PasswordInputResult)
|
||||
*
|
||||
* Flag unwinding for this specific `case` will depend on which flag gets unwound first:
|
||||
* - If KM flag gets unwound first, remove all code (in this `case`) after the call
|
||||
* to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the
|
||||
* end-goal for this `case`.
|
||||
* - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return,
|
||||
* but unwind the auth flagging logic and then remove the method call marked with
|
||||
* the "Default Scenario" comment.
|
||||
*/
|
||||
|
||||
const accountEncryptionV2 = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
|
||||
);
|
||||
|
||||
// Scenario 1: KM flag ON
|
||||
if (accountEncryptionV2) {
|
||||
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setInitialPassword(passwordInputResult);
|
||||
// Scenario 2: KM flag OFF, Auth flag ON
|
||||
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
|
||||
/**
|
||||
* If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey,
|
||||
* newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late
|
||||
* to the PasswordInputResult before calling setInitialPassword().
|
||||
*
|
||||
* This is a temporary state. The end-goal will be to use KM's V2Encryption method above.
|
||||
*/
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
|
||||
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
|
||||
assertTruthy(this.email, "email", ctx);
|
||||
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
passwordInputResult.newPassword,
|
||||
this.email.trim().toLowerCase(),
|
||||
passwordInputResult.kdfConfig,
|
||||
);
|
||||
|
||||
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
|
||||
passwordInputResult.newPassword,
|
||||
newMasterKey,
|
||||
HashPurpose.ServerAuthorization,
|
||||
);
|
||||
|
||||
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
|
||||
passwordInputResult.newPassword,
|
||||
newMasterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
passwordInputResult.newMasterKey = newMasterKey;
|
||||
passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash;
|
||||
passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash;
|
||||
|
||||
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above)
|
||||
return;
|
||||
}
|
||||
|
||||
// Default Scenario: both flags OFF
|
||||
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default)
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials {
|
||||
*/
|
||||
export abstract class SetInitialPasswordService {
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*
|
||||
* Sets an initial password for an existing authed user who is either:
|
||||
* - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER}
|
||||
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
@@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
constructor(
|
||||
private auditService: AuditService,
|
||||
private cipherService: CipherService,
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
@@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
}
|
||||
|
||||
if (!this.email) {
|
||||
throw new Error("Email is required to create master key.");
|
||||
throw new Error("Email not found.");
|
||||
}
|
||||
|
||||
// 1. Determine kdfConfig
|
||||
@@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit {
|
||||
this.kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
} else {
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not passed down");
|
||||
throw new Error("userId not found.");
|
||||
}
|
||||
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
|
||||
}
|
||||
|
||||
if (this.kdfConfig == null) {
|
||||
throw new Error("KdfConfig is required to create master key.");
|
||||
throw new Error("KdfConfig not found.");
|
||||
}
|
||||
|
||||
const salt =
|
||||
@@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
|
||||
: this.masterPasswordService.emailToSalt(this.email);
|
||||
if (salt == null) {
|
||||
throw new Error("Salt is required to create master key.");
|
||||
throw new Error("Salt not found.");
|
||||
}
|
||||
|
||||
// 2. Verify current password is correct (if necessary)
|
||||
@@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
|
||||
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
|
||||
);
|
||||
|
||||
if (newApisWithInputPasswordFlagEnabled) {
|
||||
// 4. Build a PasswordInputResult object
|
||||
const passwordInputResult: PasswordInputResult = {
|
||||
newPassword,
|
||||
kdfConfig: this.kdfConfig,
|
||||
salt,
|
||||
newPasswordHint,
|
||||
newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143
|
||||
};
|
||||
|
||||
if (
|
||||
this.flow === InputPasswordFlow.ChangePassword ||
|
||||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
|
||||
) {
|
||||
passwordInputResult.currentPassword = currentPassword;
|
||||
}
|
||||
|
||||
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
|
||||
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
|
||||
}
|
||||
|
||||
// 5. Emit and return PasswordInputResult object
|
||||
this.onPasswordFormSubmit.emit(passwordInputResult);
|
||||
return passwordInputResult;
|
||||
}
|
||||
|
||||
/*******************************************************************
|
||||
* The following code (within this `try`) to be removed in PM-28143
|
||||
*******************************************************************/
|
||||
|
||||
// 4. Create cryptographic keys and build a PasswordInputResult object
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
newPassword,
|
||||
|
||||
@@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts";
|
||||
|
||||
# InputPassword Component
|
||||
|
||||
The `InputPasswordComponent` allows a user to enter master password related credentials.
|
||||
Specifically, it does the following:
|
||||
The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting
|
||||
an initial password or changing an existing password. Specifically, it does the following:
|
||||
|
||||
1. Displays form fields in the UI
|
||||
2. Validates form fields
|
||||
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
|
||||
`newServerMasterKeyHash`, etc.)
|
||||
4. Emits the generated properties to the parent component
|
||||
3. Emits values to the parent component
|
||||
|
||||
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
|
||||
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
|
||||
@@ -30,7 +28,6 @@ those values as needed.
|
||||
- [The InputPasswordFlow](#the-inputpasswordflow)
|
||||
- [Use Cases](#use-cases)
|
||||
- [HTML - Form Fields](#html---form-fields)
|
||||
- [TypeScript - Credential Generation](#typescript---credential-generation)
|
||||
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
|
||||
- [Validation](#validation)
|
||||
- [Submit Logic](#submit-logic)
|
||||
@@ -44,20 +41,20 @@ those values as needed.
|
||||
**Required**
|
||||
|
||||
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
|
||||
which form input elements will be displayed in the UI and which cryptographic keys will be created
|
||||
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
|
||||
`InputPasswordFlow` options.
|
||||
which form input elements will be displayed in the UI and which values will be emitted.
|
||||
[Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow`
|
||||
options.
|
||||
|
||||
**Optional (sometimes)**
|
||||
|
||||
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
|
||||
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
|
||||
the `email` and/or `userId` is present in certain flows, while not present in other flows.
|
||||
These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are
|
||||
not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the
|
||||
`email` and/or `userId` is present in certain flows, while not present in other flows.
|
||||
|
||||
- `email` - allows the `InputPasswordComponent` to generate a master key
|
||||
- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed)
|
||||
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
|
||||
verify that a current password is correct, and perform validation prior to user key rotation on
|
||||
the parent
|
||||
verify that a current password is correct, and perform validation prior to user key rotation (if
|
||||
selected) on the parent
|
||||
|
||||
**Optional**
|
||||
|
||||
@@ -87,8 +84,7 @@ These `@Inputs` are truly optional.
|
||||
## The `InputPasswordFlow`
|
||||
|
||||
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
|
||||
credential generation logic of the component. It is important for the dev to understand when to use
|
||||
each flow.
|
||||
logic of the component. It is important for the dev to understand when to use each flow.
|
||||
|
||||
### Use Cases
|
||||
|
||||
@@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
|
||||
|
||||
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
|
||||
their initial password
|
||||
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
|
||||
starting role that requires them to have/set their initial password
|
||||
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset
|
||||
password permission ("manage account recovery") from the start, which requires them to have/set
|
||||
their initial password
|
||||
- A note on JIT provisioned user flows:
|
||||
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
|
||||
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
|
||||
@@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
|
||||
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
|
||||
initial password, their account does not yet exist in the database, and will only be created
|
||||
once they set an initial password.
|
||||
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
|
||||
requires them to have/set their initial password
|
||||
- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset
|
||||
password persmission ("manage account recovery"), which now requires the user to have/set their
|
||||
initial password
|
||||
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
|
||||
have/set their initial password<br /><br />
|
||||
|
||||
@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
|
||||
|
||||
Used in scenarios where we simply want to offer the user the ability to change their password:
|
||||
|
||||
- User clicks an org email invite link an logs in with their password which does not meet the org's
|
||||
- User clicks an org email invite link and logs in with their password which does not meet the org's
|
||||
policy requirements
|
||||
- User logs in with password that does not meet the org's policy requirements
|
||||
- User logs in after their password was reset via Account Recovery (and now they must change their
|
||||
@@ -156,26 +154,10 @@ which form field UI elements get displayed.
|
||||
|
||||
<br />
|
||||
|
||||
### TypeScript - Credential Generation
|
||||
|
||||
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
|
||||
- These flows involve a user setting their password for the first time. Therefore on submit the
|
||||
component will only generate new credentials (`newMasterKey`) and not current credentials
|
||||
(`currentMasterKey`).<br /><br />
|
||||
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
|
||||
- These flows both require the user to enter a current password along with a new password.
|
||||
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
|
||||
with new credentials (`newMasterKey`).<br /><br />
|
||||
- **`ChangePasswordDelegation`**
|
||||
- This flow does not generate any credentials, but simply validates the new password and emits it
|
||||
up to the parent.
|
||||
|
||||
<br />
|
||||
|
||||
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
|
||||
|
||||
These two flows are similar in that they display the same form fields and only generate new
|
||||
credentials, but we need to keep them separate for the following reasons:
|
||||
These two flows are similar in that they display the same form fields, but we need to keep them
|
||||
separate for the following reasons:
|
||||
|
||||
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
|
||||
**thus NO active account `userId`**:
|
||||
@@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons:
|
||||
and **thus an active account `userId`**:
|
||||
|
||||
The presence or absence of an active account `userId` is important because it determines how we get
|
||||
the correct `kdfConfig` prior to key generation:
|
||||
the correct `kdfConfig`:
|
||||
|
||||
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
|
||||
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
|
||||
@@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
|
||||
checkbox)
|
||||
- Checks that the new password adheres to any enforced master password policies that were
|
||||
optionally passed down by the parent
|
||||
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
|
||||
`newServerMasterKeyHash`, etc.)
|
||||
3. Emits those cryptographic properties up to the parent (along with other values defined in
|
||||
`PasswordInputResult`) to be used by the parent as needed.
|
||||
2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be
|
||||
used by the parent as needed.
|
||||
|
||||
```typescript
|
||||
export interface PasswordInputResult {
|
||||
currentPassword?: string;
|
||||
currentMasterKey?: MasterKey;
|
||||
currentServerMasterKeyHash?: string;
|
||||
currentLocalMasterKeyHash?: string;
|
||||
|
||||
newPassword: string;
|
||||
newPasswordHint?: string;
|
||||
newMasterKey?: MasterKey;
|
||||
newServerMasterKeyHash?: string;
|
||||
newLocalMasterKeyHash?: string;
|
||||
|
||||
kdfConfig?: KdfConfig;
|
||||
salt?: MasterPasswordSalt;
|
||||
newPasswordHint?: string;
|
||||
rotateUserKey?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
@@ -59,6 +60,13 @@ export default {
|
||||
getAllDecrypted: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
// Can remove ConfigService from component and stories in PM-28143 (if it is no longer used)
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag: () => false, // default to false since flag does not effect UI
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: KdfConfigService,
|
||||
useValue: {
|
||||
|
||||
@@ -10,6 +10,20 @@ export interface PasswordInputResult {
|
||||
newPasswordHint?: string;
|
||||
rotateUserKey?: boolean;
|
||||
|
||||
/**
|
||||
* Temporary property that persists the flag state through the entire set/change password process.
|
||||
* This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves.
|
||||
*
|
||||
* The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever
|
||||
* require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off.
|
||||
* Flagging for those 2 flows will be done via the ConfigService in their respective services.
|
||||
*
|
||||
* To be removed in PM-28143
|
||||
*/
|
||||
newApisWithInputPasswordFlagEnabled?: boolean;
|
||||
|
||||
// The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143
|
||||
|
||||
/** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */
|
||||
currentMasterKey?: MasterKey;
|
||||
/** @deprecated */
|
||||
|
||||
@@ -64,14 +64,13 @@ describe("SendTokenService", () => {
|
||||
"send_id_required",
|
||||
"password_hash_b64_required",
|
||||
"email_required",
|
||||
"email_and_otp_required_otp_sent",
|
||||
"email_and_otp_required",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
|
||||
"send_id_invalid",
|
||||
"password_hash_b64_invalid",
|
||||
"email_invalid",
|
||||
"otp_invalid",
|
||||
"otp_generation_failed",
|
||||
"unknown",
|
||||
|
||||
@@ -31,13 +31,6 @@ export function passwordHashB64Invalid(
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
|
||||
}
|
||||
|
||||
export type EmailInvalid = InvalidGrant & {
|
||||
send_access_error_type: "email_invalid";
|
||||
};
|
||||
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
|
||||
}
|
||||
|
||||
export type OtpInvalid = InvalidGrant & {
|
||||
send_access_error_type: "otp_invalid";
|
||||
};
|
||||
|
||||
@@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
|
||||
}
|
||||
|
||||
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
|
||||
send_access_error_type: "email_and_otp_required_otp_sent";
|
||||
export type EmailAndOtpRequired = InvalidRequest & {
|
||||
send_access_error_type: "email_and_otp_required";
|
||||
};
|
||||
|
||||
export function emailAndOtpRequiredEmailSent(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is EmailAndOtpRequiredEmailSent {
|
||||
return (
|
||||
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
|
||||
);
|
||||
export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required";
|
||||
}
|
||||
|
||||
export type UnknownInvalidRequest = InvalidRequest & {
|
||||
|
||||
@@ -18,9 +18,11 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
|
||||
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",
|
||||
@@ -109,6 +111,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.MembersComponentRefactor]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
|
||||
@@ -137,6 +140,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
|
||||
[FeatureFlag.SafariAccountSwitching]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
87
libs/importer/src/importers/buttercup-csv-importer.spec.ts
Normal file
87
libs/importer/src/importers/buttercup-csv-importer.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ export type BitwardenCsvExportType = {
|
||||
login_password: string;
|
||||
login_totp: string;
|
||||
favorite: number | null;
|
||||
archivedDate: string | null;
|
||||
};
|
||||
|
||||
export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & {
|
||||
|
||||
@@ -38,14 +38,16 @@
|
||||
</button>
|
||||
}
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||
[cipherId]="cipher().id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
@if (cipher().edit) {
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||
[cipherId]="cipher().id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
}
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</li>
|
||||
@@ -54,46 +56,48 @@
|
||||
}
|
||||
|
||||
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
|
||||
<bit-card>
|
||||
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
|
||||
{{ "addAttachment" | i18n }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<!-- Input elements are notoriously difficult to style, --->
|
||||
<!-- The native `<input>` will be used for screen readers -->
|
||||
<!-- Visual & keyboard users will interact with the styled button element -->
|
||||
<input
|
||||
#fileInput
|
||||
class="tw-sr-only"
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
aria-describedby="fileHelp"
|
||||
tabindex="-1"
|
||||
required
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
(click)="fileInput.click()"
|
||||
class="tw-whitespace-nowrap"
|
||||
>
|
||||
{{ "chooseFile" | i18n }}
|
||||
</button>
|
||||
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
|
||||
{{
|
||||
this.attachmentForm.controls.file?.value
|
||||
? this.attachmentForm.controls.file.value.name
|
||||
: ("noFileChosen" | i18n)
|
||||
}}
|
||||
</p>
|
||||
@if (cipher()?.edit) {
|
||||
<bit-card>
|
||||
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
|
||||
{{ "addAttachment" | i18n }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<!-- Input elements are notoriously difficult to style, --->
|
||||
<!-- The native `<input>` will be used for screen readers -->
|
||||
<!-- Visual & keyboard users will interact with the styled button element -->
|
||||
<input
|
||||
#fileInput
|
||||
class="tw-sr-only"
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
aria-describedby="fileHelp"
|
||||
tabindex="-1"
|
||||
required
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
(click)="fileInput.click()"
|
||||
class="tw-whitespace-nowrap"
|
||||
>
|
||||
{{ "chooseFile" | i18n }}
|
||||
</button>
|
||||
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
|
||||
{{
|
||||
this.attachmentForm.controls.file?.value
|
||||
? this.attachmentForm.controls.file.value.name
|
||||
: ("noFileChosen" | i18n)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
|
||||
{{ "maxFileSizeSansPunctuation" | i18n }}
|
||||
</p>
|
||||
</bit-card>
|
||||
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
|
||||
{{ "maxFileSizeSansPunctuation" | i18n }}
|
||||
</p>
|
||||
</bit-card>
|
||||
}
|
||||
</form>
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => {
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
edit: true,
|
||||
} as CipherView;
|
||||
|
||||
const cipherDomain = {
|
||||
@@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => {
|
||||
let file: File;
|
||||
|
||||
beforeEach(() => {
|
||||
const nonEditableCipherView = { ...cipherView, edit: false };
|
||||
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
|
||||
fixture.detectChanges();
|
||||
|
||||
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
|
||||
file = new File([""], "attachment.txt", { type: "text/plain" });
|
||||
|
||||
@@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => {
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
async function setup(): Promise<void> {
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
submitBtnFixture = TestBed.createComponent(ButtonComponent);
|
||||
|
||||
// Set organizationId BEFORE cipherId so the effect picks it up
|
||||
fixture.componentRef.setInput("organizationId", organization.id);
|
||||
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
await waitForInitialization();
|
||||
const nonEditableCipherView = { ...cipherView, edit: false };
|
||||
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('emits "onCloseButtonPress"', async () => {
|
||||
await setup();
|
||||
const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAttachment", () => {
|
||||
|
||||
@@ -105,6 +105,8 @@ export class CipherAttachmentsComponent {
|
||||
/** Emits after a file has been successfully removed */
|
||||
readonly onRemoveSuccess = output<void>();
|
||||
|
||||
readonly onCloseButtonPress = output<void>();
|
||||
|
||||
protected readonly organization = signal<Organization | null>(null);
|
||||
protected readonly cipher = signal<CipherView | null>(null);
|
||||
|
||||
@@ -154,7 +156,7 @@ export class CipherAttachmentsComponent {
|
||||
// Update the initial state of the submit button
|
||||
const btn = this.submitBtn();
|
||||
if (btn) {
|
||||
btn.disabled.set(!this.attachmentForm.valid);
|
||||
btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -192,6 +194,12 @@ export class CipherAttachmentsComponent {
|
||||
|
||||
/** Save the attachments to the cipher */
|
||||
submit = async () => {
|
||||
//user can't edit cipher and will close the bit-dialog
|
||||
if (!(this.cipher()?.edit ?? false)) {
|
||||
this.onCloseButtonPress.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onUploadStarted.emit();
|
||||
|
||||
const file = this.attachmentForm.value.file;
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
(onUploadSuccess)="uploadSuccessful()"
|
||||
(onUploadFailed)="uploadFailed()"
|
||||
(onRemoveSuccess)="removalSuccessful()"
|
||||
(onCloseButtonPress)="closeButtonPressed()"
|
||||
></app-cipher-attachments>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
|
||||
{{ "upload" | i18n }}
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
|
||||
@@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => {
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
|
||||
});
|
||||
|
||||
it("closes the dialog with 'closed' result on closedButtonPressed", () => {
|
||||
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||
|
||||
component.closeButtonPressed();
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Inject } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm
|
||||
|
||||
export interface AttachmentsDialogParams {
|
||||
cipherId: CipherId;
|
||||
canEditCipher?: boolean;
|
||||
admin?: boolean;
|
||||
organizationId?: OrganizationId;
|
||||
}
|
||||
@@ -51,7 +53,9 @@ export class AttachmentsV2Component {
|
||||
cipherId: CipherId;
|
||||
admin: boolean = false;
|
||||
organizationId?: OrganizationId;
|
||||
canEditCipher: boolean;
|
||||
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
|
||||
buttonText: string;
|
||||
private isUploading = false;
|
||||
|
||||
/**
|
||||
@@ -62,10 +66,14 @@ export class AttachmentsV2Component {
|
||||
constructor(
|
||||
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
|
||||
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
this.cipherId = params.cipherId;
|
||||
this.organizationId = params.organizationId;
|
||||
this.admin = params.admin ?? false;
|
||||
this.canEditCipher = params?.canEditCipher ?? false;
|
||||
this.buttonText =
|
||||
this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +148,10 @@ export class AttachmentsV2Component {
|
||||
action: AttachmentDialogResult.Removed,
|
||||
});
|
||||
}
|
||||
|
||||
closeButtonPressed() {
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Closed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -23,8 +23,8 @@
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.506",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@koa/multer": "4.0.0",
|
||||
@@ -4982,10 +4982,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/commercial-sdk-internal": {
|
||||
"version": "0.2.0-main.470",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz",
|
||||
"integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==",
|
||||
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
|
||||
"version": "0.2.0-main.506",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz",
|
||||
"integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==",
|
||||
"dependencies": {
|
||||
"type-fest": "^4.41.0"
|
||||
}
|
||||
@@ -5087,10 +5086,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/sdk-internal": {
|
||||
"version": "0.2.0-main.470",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz",
|
||||
"integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==",
|
||||
"license": "GPL-3.0",
|
||||
"version": "0.2.0-main.506",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz",
|
||||
"integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==",
|
||||
"dependencies": {
|
||||
"type-fest": "^4.41.0"
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.506",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@koa/multer": "4.0.0",
|
||||
|
||||
Reference in New Issue
Block a user