mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 19:23:19 +00:00
Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval
This commit is contained in:
49
.github/workflows/build-desktop.yml
vendored
49
.github/workflows/build-desktop.yml
vendored
@@ -185,6 +185,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Set up environment
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -334,6 +341,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Set up environment
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -475,6 +489,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Install AST
|
||||
run: dotnet tool install --global AzureSignTool --version 4.0.1
|
||||
|
||||
@@ -734,6 +755,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Install AST
|
||||
run: dotnet tool install --global AzureSignTool --version 4.0.1
|
||||
|
||||
@@ -976,6 +1004,13 @@ jobs:
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
node --version
|
||||
@@ -1206,6 +1241,13 @@ jobs:
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
node --version
|
||||
@@ -1471,6 +1513,13 @@ jobs:
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
cache-targets: "true"
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
node --version
|
||||
|
||||
2
.github/workflows/build-web.yml
vendored
2
.github/workflows/build-web.yml
vendored
@@ -315,7 +315,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
|
||||
1
.github/workflows/review-code.yml
vendored
1
.github/workflows/review-code.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -49,7 +49,9 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
# NOTE: versions of actions/create-github-app-token after 2.0.3 break this workflow
|
||||
# Remediation is tracked in https://bitwarden.atlassian.net/browse/PM-28174
|
||||
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
@@ -594,6 +594,9 @@
|
||||
"viewAll": {
|
||||
"message": "View all"
|
||||
},
|
||||
"viewLess": {
|
||||
"message": "View less"
|
||||
},
|
||||
"viewLogin": {
|
||||
"message": "View login"
|
||||
},
|
||||
|
||||
@@ -86,12 +86,12 @@
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
<auth-vault-timeout-input
|
||||
<bit-session-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
</bit-session-timeout-input>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
||||
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
|
||||
import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { LockService } from "@bitwarden/auth/common";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -69,7 +69,10 @@ import {
|
||||
BiometricStateService,
|
||||
BiometricsStatus,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
SessionTimeoutInputComponent,
|
||||
SessionTimeoutSettingsComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
@@ -106,7 +109,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
|
||||
SessionTimeoutSettingsComponent,
|
||||
SpotlightComponent,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutInputComponent,
|
||||
],
|
||||
})
|
||||
export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type FocusedFieldData = {
|
||||
accountCreationFieldType?: string;
|
||||
showPasskeys?: boolean;
|
||||
focusedFieldForm?: string;
|
||||
focusedFieldOpid?: string;
|
||||
};
|
||||
|
||||
export type InlineMenuElementPosition = {
|
||||
|
||||
@@ -1176,6 +1176,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
|
||||
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
|
||||
});
|
||||
|
||||
if (totpCode) {
|
||||
@@ -1861,6 +1862,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: false,
|
||||
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
|
||||
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
|
||||
});
|
||||
|
||||
globalThis.setTimeout(async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface AutoFillOptions {
|
||||
allowTotpAutofill?: boolean;
|
||||
autoSubmitLogin?: boolean;
|
||||
focusedFieldForm?: string;
|
||||
focusedFieldOpid?: string;
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
@@ -47,6 +48,7 @@ export interface GenerateFillScriptOptions {
|
||||
cipher: CipherView;
|
||||
tabUrl: string;
|
||||
defaultUriMatch: UriMatchStrategySetting;
|
||||
focusedFieldOpid?: string;
|
||||
}
|
||||
|
||||
export type CollectPageDetailsResponseMessage = {
|
||||
|
||||
@@ -975,6 +975,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
showPasskeys: !!autofillFieldData?.showPasskeys,
|
||||
accountCreationFieldType: autofillFieldData?.accountCreationFieldType,
|
||||
focusedFieldForm: autofillFieldData?.form,
|
||||
focusedFieldOpid: autofillFieldData?.opid,
|
||||
};
|
||||
|
||||
const allFields = this.formFieldElements;
|
||||
@@ -1085,7 +1086,15 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
pageDetails,
|
||||
)
|
||||
) {
|
||||
this.setQualifiedAccountCreationFillType(autofillFieldData);
|
||||
const hasUsernameField = [...this.formFieldElements.values()].some((field) =>
|
||||
this.inlineMenuFieldQualificationService.isUsernameField(field),
|
||||
);
|
||||
|
||||
if (hasUsernameField) {
|
||||
void this.setQualifiedLoginFillType(autofillFieldData);
|
||||
} else {
|
||||
this.setQualifiedAccountCreationFillType(autofillFieldData);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import AutofillPageDetails from "../models/autofill-page-details";
|
||||
import AutofillScript from "../models/autofill-script";
|
||||
import {
|
||||
createAutofillFieldMock,
|
||||
createAutofillFormMock,
|
||||
createAutofillPageDetailsMock,
|
||||
createAutofillScriptMock,
|
||||
createChromeTabMock,
|
||||
@@ -2309,6 +2310,147 @@ describe("AutofillService", () => {
|
||||
untrustedIframe: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a focused username field", () => {
|
||||
let focusedField: AutofillField;
|
||||
let passwordField: AutofillField;
|
||||
|
||||
beforeEach(() => {
|
||||
focusedField = createAutofillFieldMock({
|
||||
opid: "focused-username",
|
||||
type: "text",
|
||||
form: "form1",
|
||||
elementNumber: 1,
|
||||
});
|
||||
passwordField = createAutofillFieldMock({
|
||||
opid: "password",
|
||||
type: "password",
|
||||
form: "form1",
|
||||
elementNumber: 2,
|
||||
});
|
||||
pageDetails.forms = {
|
||||
form1: createAutofillFormMock({ opid: "form1" }),
|
||||
};
|
||||
options.focusedFieldOpid = "focused-username";
|
||||
jest.spyOn(autofillService as any, "inUntrustedIframe").mockResolvedValue(false);
|
||||
jest.spyOn(AutofillService, "fillByOpid");
|
||||
});
|
||||
|
||||
it("will return early when no matching password is found and set autosubmit if enabled", async () => {
|
||||
pageDetails.fields = [focusedField];
|
||||
options.autoSubmitLogin = true;
|
||||
|
||||
const value = await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(1);
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
focusedField,
|
||||
options.cipher.login.username,
|
||||
);
|
||||
expect(value.autosubmit).toEqual(["form1"]);
|
||||
});
|
||||
|
||||
it("will prioritize focused field and skip passwords in different forms", async () => {
|
||||
const otherUsername = createAutofillFieldMock({
|
||||
opid: "other-username",
|
||||
type: "text",
|
||||
form: "form1",
|
||||
elementNumber: 2,
|
||||
});
|
||||
const passwordDifferentForm = createAutofillFieldMock({
|
||||
opid: "password-different",
|
||||
type: "password",
|
||||
form: "form2",
|
||||
elementNumber: 1,
|
||||
});
|
||||
pageDetails.fields = [focusedField, otherUsername, passwordField, passwordDifferentForm];
|
||||
pageDetails.forms.form2 = createAutofillFormMock({ opid: "form2" });
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
focusedField,
|
||||
options.cipher.login.username,
|
||||
);
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
passwordField,
|
||||
options.cipher.login.password,
|
||||
);
|
||||
expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
otherUsername,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
passwordDifferentForm,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("will not fill focused field if already in filledFields", async () => {
|
||||
pageDetails.fields = [focusedField, passwordField];
|
||||
filledFields[focusedField.opid] = focusedField;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
focusedField,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
passwordField,
|
||||
options.cipher.login.password,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["email", "email"],
|
||||
["tel", "tel"],
|
||||
])("will treat focused %s field as username field", async (type, opid) => {
|
||||
const focusedTypedField = createAutofillFieldMock({
|
||||
opid: `focused-${opid}`,
|
||||
type: type as "email" | "tel",
|
||||
form: "form1",
|
||||
elementNumber: 1,
|
||||
});
|
||||
pageDetails.fields = [focusedTypedField, passwordField];
|
||||
options.focusedFieldOpid = `focused-${opid}`;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
|
||||
fillScript,
|
||||
focusedTypedField,
|
||||
options.cipher.login.username,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -451,6 +451,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
cipher: options.cipher,
|
||||
tabUrl: tab.url,
|
||||
defaultUriMatch: defaultUriMatch,
|
||||
focusedFieldOpid: options.focusedFieldOpid,
|
||||
});
|
||||
|
||||
if (!fillScript || !fillScript.script || !fillScript.script.length) {
|
||||
@@ -837,7 +838,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
|
||||
const passwords: AutofillField[] = [];
|
||||
const usernames: AutofillField[] = [];
|
||||
const usernames = new Map<string, AutofillField>();
|
||||
const totps: AutofillField[] = [];
|
||||
let pf: AutofillField = null;
|
||||
let username: AutofillField = null;
|
||||
@@ -871,6 +872,70 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
const prioritizedPasswordFields =
|
||||
loginPasswordFields.length > 0 ? loginPasswordFields : registrationPasswordFields;
|
||||
|
||||
const focusedField =
|
||||
options.focusedFieldOpid &&
|
||||
pageDetails.fields.find((f) => f.opid === options.focusedFieldOpid);
|
||||
const focusedForm = focusedField?.form;
|
||||
|
||||
const isFocusedTotpField =
|
||||
focusedField &&
|
||||
options.allowTotpAutofill &&
|
||||
(focusedField.type === "text" ||
|
||||
focusedField.type === "number" ||
|
||||
focusedField.type === "tel") &&
|
||||
(AutofillService.fieldIsFuzzyMatch(focusedField, [
|
||||
...AutoFillConstants.TotpFieldNames,
|
||||
...AutoFillConstants.AmbiguousTotpFieldNames,
|
||||
]) ||
|
||||
focusedField.autoCompleteType === "one-time-code") &&
|
||||
!AutofillService.fieldIsFuzzyMatch(focusedField, [
|
||||
...AutoFillConstants.RecoveryCodeFieldNames,
|
||||
]);
|
||||
|
||||
const focusedUsernameField =
|
||||
focusedField &&
|
||||
!isFocusedTotpField &&
|
||||
login.username &&
|
||||
(focusedField.type === "text" ||
|
||||
focusedField.type === "email" ||
|
||||
focusedField.type === "tel") &&
|
||||
focusedField;
|
||||
|
||||
const passwordMatchesFocused = (pf: AutofillField): boolean =>
|
||||
!focusedField
|
||||
? true
|
||||
: focusedForm != null
|
||||
? pf.form === focusedForm
|
||||
: focusedUsernameField &&
|
||||
pf.form == null &&
|
||||
this.findUsernameField(pageDetails, pf, false, false, true)?.opid ===
|
||||
focusedUsernameField.opid;
|
||||
|
||||
const getUsernameForPassword = (
|
||||
pf: AutofillField,
|
||||
withoutForm: boolean,
|
||||
): AutofillField | null => {
|
||||
// use focused username if it matches this password, otherwise fall back to finding username field before password
|
||||
if (focusedUsernameField && passwordMatchesFocused(pf)) {
|
||||
return focusedUsernameField;
|
||||
}
|
||||
return this.findUsernameField(pageDetails, pf, false, false, withoutForm);
|
||||
};
|
||||
|
||||
if (focusedUsernameField && !prioritizedPasswordFields.some(passwordMatchesFocused)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(filledFields, focusedUsernameField.opid)) {
|
||||
filledFields[focusedUsernameField.opid] = focusedUsernameField;
|
||||
AutofillService.fillByOpid(fillScript, focusedUsernameField, login.username);
|
||||
if (options.autoSubmitLogin && focusedUsernameField.form) {
|
||||
fillScript.autosubmit = [focusedUsernameField.form];
|
||||
}
|
||||
return AutofillService.setFillScriptForFocus(
|
||||
{ [focusedUsernameField.opid]: focusedUsernameField },
|
||||
fillScript,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const formKey in pageDetails.forms) {
|
||||
// eslint-disable-next-line
|
||||
if (!pageDetails.forms.hasOwnProperty(formKey)) {
|
||||
@@ -878,20 +943,25 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
|
||||
prioritizedPasswordFields.forEach((passField) => {
|
||||
if (focusedField && !passwordMatchesFocused(passField)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pf = passField;
|
||||
passwords.push(pf);
|
||||
|
||||
if (login.username) {
|
||||
username = this.findUsernameField(pageDetails, pf, false, false, false);
|
||||
|
||||
username = getUsernameForPassword(pf, false);
|
||||
if (username) {
|
||||
usernames.push(username);
|
||||
usernames.set(username.opid, username);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowTotpAutofill && login.totp) {
|
||||
totp = this.findTotpField(pageDetails, pf, false, false, false);
|
||||
|
||||
totp =
|
||||
isFocusedTotpField && passwordMatchesFocused(passField)
|
||||
? focusedField
|
||||
: this.findTotpField(pageDetails, pf, false, false, false);
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
}
|
||||
@@ -900,24 +970,30 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
|
||||
if (passwordFields.length && !passwords.length) {
|
||||
// The page does not have any forms with password fields. Use the first password field on the page and the
|
||||
// input field just before it as the username.
|
||||
pf = prioritizedPasswordFields[0];
|
||||
passwords.push(pf);
|
||||
// in the event that password fields exist but weren't processed within form elements.
|
||||
// select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password.
|
||||
const passwordFieldToUse = focusedField
|
||||
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
|
||||
: prioritizedPasswordFields[0];
|
||||
|
||||
if (login.username && pf.elementNumber > 0) {
|
||||
username = this.findUsernameField(pageDetails, pf, false, false, true);
|
||||
if (passwordFieldToUse) {
|
||||
passwords.push(passwordFieldToUse);
|
||||
|
||||
if (username) {
|
||||
usernames.push(username);
|
||||
if (login.username && passwordFieldToUse.elementNumber > 0) {
|
||||
username = getUsernameForPassword(passwordFieldToUse, true);
|
||||
if (username) {
|
||||
usernames.set(username.opid, username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) {
|
||||
totp = this.findTotpField(pageDetails, pf, false, false, true);
|
||||
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) {
|
||||
totp =
|
||||
isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse)
|
||||
? focusedField
|
||||
: this.findTotpField(pageDetails, passwordFieldToUse, false, false, true);
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,7 +1027,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
totps.push(field);
|
||||
return;
|
||||
case isFillableUsernameField:
|
||||
usernames.push(field);
|
||||
usernames.set(field.opid, field);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -960,9 +1036,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
|
||||
const formElementsSet = new Set<string>();
|
||||
usernames.forEach((u) => {
|
||||
// eslint-disable-next-line
|
||||
if (filledFields.hasOwnProperty(u.opid)) {
|
||||
const usernamesToFill = focusedUsernameField ? [focusedUsernameField] : [...usernames.values()];
|
||||
|
||||
usernamesToFill.forEach((u) => {
|
||||
if (Object.prototype.hasOwnProperty.call(filledFields, u.opid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2330,12 +2407,14 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
|
||||
const includesUsernameFieldName =
|
||||
this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1;
|
||||
const isInSameForm = f.form === passwordField.form;
|
||||
// only consider fields in same form if both have non-null form values
|
||||
// null forms are treated as separate
|
||||
const isInSameForm =
|
||||
f.form != null && passwordField.form != null && f.form === passwordField.form;
|
||||
|
||||
// An email or tel field in the same form as the password field is likely a qualified
|
||||
// candidate for autofill, even if visibility checks are unreliable
|
||||
const isQualifiedUsernameField =
|
||||
f.form === passwordField.form && (f.type === "email" || f.type === "tel");
|
||||
const isQualifiedUsernameField = isInSameForm && (f.type === "email" || f.type === "tel");
|
||||
|
||||
if (
|
||||
!f.disabled &&
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle>{{ "confirmAutofill" | i18n }}</span>
|
||||
<bit-dialog [title]="'confirmAutofill' | i18n">
|
||||
<div bitDialogContent>
|
||||
<p bitTypography="body2">
|
||||
{{ "confirmAutofillDesc" | i18n }}
|
||||
</p>
|
||||
@if (savedUrls.length === 1) {
|
||||
@if (savedUrls().length === 1) {
|
||||
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-medium">
|
||||
{{ "savedWebsite" | i18n }}
|
||||
</p>
|
||||
<bit-callout [title]="null" type="success" icon="bwi-globe">
|
||||
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="savedUrls[0]">
|
||||
{{ savedUrls[0] }}
|
||||
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="savedUrls()[0]">
|
||||
{{ savedUrls()[0] }}
|
||||
</div>
|
||||
</bit-callout>
|
||||
}
|
||||
@if (savedUrls.length > 1) {
|
||||
@if (savedUrls().length > 1) {
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-mt-4 tw-mb-1 tw-pt-2">
|
||||
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-medium">
|
||||
{{ "savedWebsites" | i18n: savedUrls.length }}
|
||||
{{ "savedWebsites" | i18n: savedUrls().length }}
|
||||
</p>
|
||||
<button
|
||||
*ngIf="!savedUrlsExpanded"
|
||||
type="button"
|
||||
bitLink
|
||||
class="tw-text-sm tw-font-medium tw-cursor-pointer"
|
||||
(click)="viewAllSavedUrls()"
|
||||
(click)="toggleSavedUrlExpandedState()"
|
||||
>
|
||||
{{ "viewAll" | i18n }}
|
||||
{{ (savedUrlsExpanded() ? "viewLess" : "viewAll") | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-pt-2" [ngClass]="savedUrlsListClass">
|
||||
<div class="-tw-mt-2" *ngFor="let url of savedUrls">
|
||||
<bit-callout [title]="null" type="success" icon="bwi-globe">
|
||||
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="url">
|
||||
{{ url }}
|
||||
</div>
|
||||
</bit-callout>
|
||||
</div>
|
||||
<div class="tw-pt-2" [ngClass]="savedUrlsListClass()">
|
||||
@for (url of savedUrls(); track url) {
|
||||
<div class="-tw-mt-2">
|
||||
<bit-callout [title]="null" type="success" icon="bwi-globe">
|
||||
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="url">
|
||||
{{ url }}
|
||||
</div>
|
||||
</bit-callout>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-medium">
|
||||
{{ "currentWebsite" | i18n }}
|
||||
</p>
|
||||
<bit-callout [title]="null" type="warning" icon="bwi-globe">
|
||||
<div [appA11yTitle]="currentUrl" class="tw-font-mono tw-line-clamp-1 tw-break-all">
|
||||
{{ currentUrl }}
|
||||
<div [appA11yTitle]="currentUrl()" class="tw-font-mono tw-line-clamp-1 tw-break-all">
|
||||
{{ currentUrl() }}
|
||||
</div>
|
||||
</bit-callout>
|
||||
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
|
||||
@if (!viewOnly) {
|
||||
@if (!viewOnly()) {
|
||||
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
|
||||
{{ "autofillAndAddWebsite" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
|
||||
{{ (viewOnly ? "autofill" : "autofillWithoutAdding") | i18n }}
|
||||
{{ (viewOnly() ? "autofill" : "autofillWithoutAdding") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -29,7 +29,11 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
params?: AutofillConfirmationDialogParams;
|
||||
viewOnly?: boolean;
|
||||
}) {
|
||||
const p = options?.params ?? params;
|
||||
const base = options?.params ?? params;
|
||||
const p: AutofillConfirmationDialogParams = {
|
||||
...base,
|
||||
viewOnly: options?.viewOnly,
|
||||
};
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -46,12 +50,6 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
|
||||
const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
|
||||
const freshInstance = freshFixture.componentInstance;
|
||||
|
||||
// If needed, set viewOnly BEFORE first detectChanges so initial render reflects it.
|
||||
if (typeof options?.viewOnly !== "undefined") {
|
||||
freshInstance.viewOnly = options.viewOnly;
|
||||
}
|
||||
|
||||
freshFixture.detectChanges();
|
||||
return { fixture: freshFixture, component: freshInstance };
|
||||
}
|
||||
@@ -95,10 +93,8 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
|
||||
it("normalizes currentUrl and savedUrls via Utils.getHostname", () => {
|
||||
expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0));
|
||||
// current
|
||||
expect(component.currentUrl).toBe("example.com");
|
||||
// saved
|
||||
expect(component.savedUrls).toEqual([
|
||||
expect(component.currentUrl()).toBe("example.com");
|
||||
expect(component.savedUrls()).toEqual([
|
||||
"one.example.com",
|
||||
"two.example.com",
|
||||
"not-a-url.example",
|
||||
@@ -115,30 +111,30 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
|
||||
it("emits Canceled on close()", () => {
|
||||
const spy = jest.spyOn(dialogRef, "close");
|
||||
component["close"]();
|
||||
(component as any)["close"]();
|
||||
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled);
|
||||
});
|
||||
|
||||
it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => {
|
||||
const spy = jest.spyOn(dialogRef, "close");
|
||||
component["autofillAndAddUrl"]();
|
||||
(component as any)["autofillAndAddUrl"]();
|
||||
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
|
||||
});
|
||||
|
||||
it("emits AutofilledOnly on autofillOnly()", () => {
|
||||
const spy = jest.spyOn(dialogRef, "close");
|
||||
component["autofillOnly"]();
|
||||
(component as any)["autofillOnly"]();
|
||||
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
});
|
||||
|
||||
it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => {
|
||||
const initial = component["savedUrlsListClass"];
|
||||
it("applies collapsed list gradient class by default, then clears it after toggling", () => {
|
||||
const initial = component.savedUrlsListClass();
|
||||
expect(initial).toContain("gradient");
|
||||
|
||||
component["viewAllSavedUrls"]();
|
||||
component.toggleSavedUrlExpandedState();
|
||||
fixture.detectChanges();
|
||||
|
||||
const expanded = component["savedUrlsListClass"];
|
||||
const expanded = component.savedUrlsListClass();
|
||||
expect(expanded).toBe("");
|
||||
});
|
||||
|
||||
@@ -149,37 +145,36 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
};
|
||||
|
||||
const { component: fresh } = await createFreshFixture({ params: newParams });
|
||||
expect(fresh.savedUrls).toEqual([]);
|
||||
expect(fresh.currentUrl).toBe("bitwarden.com");
|
||||
expect(fresh.savedUrls()).toEqual([]);
|
||||
expect(fresh.currentUrl()).toBe("bitwarden.com");
|
||||
});
|
||||
|
||||
it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => {
|
||||
it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", async () => {
|
||||
const localParams: AutofillConfirmationDialogParams = {
|
||||
currentUrl: "https://sub.domain.tld/x",
|
||||
};
|
||||
|
||||
const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef);
|
||||
|
||||
expect(local.savedUrls).toEqual([]);
|
||||
expect(local.currentUrl).toBe("sub.domain.tld");
|
||||
const { component: local } = await createFreshFixture({ params: localParams });
|
||||
expect(local.savedUrls()).toEqual([]);
|
||||
expect(local.currentUrl()).toBe("sub.domain.tld");
|
||||
});
|
||||
|
||||
it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => {
|
||||
(Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com");
|
||||
(Utils.getHostname as jest.Mock)
|
||||
.mockImplementationOnce(() => "ok.example")
|
||||
.mockImplementationOnce(() => "")
|
||||
.mockImplementationOnce(() => undefined as unknown as string);
|
||||
it("filters out falsy/invalid values from Utils.getHostname in savedUrls", async () => {
|
||||
const hostSpy = jest.spyOn(Utils, "getHostname");
|
||||
hostSpy.mockImplementationOnce(() => "example.com");
|
||||
hostSpy.mockImplementationOnce(() => "ok.example");
|
||||
hostSpy.mockImplementationOnce(() => "");
|
||||
hostSpy.mockImplementationOnce(() => undefined as unknown as string);
|
||||
|
||||
const edgeParams: AutofillConfirmationDialogParams = {
|
||||
currentUrl: "https://example.com",
|
||||
savedUrls: ["https://ok.example", "://bad", "%%%"],
|
||||
};
|
||||
|
||||
const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef);
|
||||
const { component: edge } = await createFreshFixture({ params: edgeParams });
|
||||
|
||||
expect(edge.currentUrl).toBe("example.com");
|
||||
expect(edge.savedUrls).toEqual(["ok.example"]);
|
||||
expect(edge.currentUrl()).toBe("example.com");
|
||||
expect(edge.savedUrls()).toEqual(["ok.example"]);
|
||||
});
|
||||
|
||||
it("renders one current-url callout and N saved-url callouts", () => {
|
||||
@@ -196,7 +191,7 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
expect(text).toContain("two.example.com");
|
||||
});
|
||||
|
||||
it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => {
|
||||
it("shows the 'view all' button when savedUrls > 1 and toggles the button text when clicked", () => {
|
||||
const findViewAll = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
|
||||
@@ -209,35 +204,31 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
btn = findViewAll();
|
||||
expect(btn).toBeFalsy();
|
||||
expect(component.savedUrlsExpanded).toBe(true);
|
||||
expect(btn!.textContent).toContain("viewLess");
|
||||
expect(component.savedUrlsExpanded()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows autofill and save button when viewOnly is false", () => {
|
||||
component.viewOnly = false;
|
||||
// default viewOnly is false
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofill and save button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, Inject } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
TypographyModule,
|
||||
CalloutComponent,
|
||||
@@ -46,49 +46,37 @@ export type AutofillConfirmationDialogResultType = UnionOfValues<
|
||||
],
|
||||
})
|
||||
export class AutofillConfirmationDialogComponent {
|
||||
AutofillConfirmationDialogResult = AutofillConfirmationDialogResult;
|
||||
private readonly params = inject<AutofillConfirmationDialogParams>(DIALOG_DATA);
|
||||
private readonly dialogRef = inject(DialogRef<AutofillConfirmationDialogResultType>);
|
||||
|
||||
currentUrl: string = "";
|
||||
savedUrls: string[] = [];
|
||||
savedUrlsExpanded = false;
|
||||
viewOnly: boolean = false;
|
||||
readonly currentUrl = signal<string>(Utils.getHostname(this.params.currentUrl));
|
||||
readonly savedUrls = signal<string[]>(
|
||||
(this.params.savedUrls ?? []).map((u) => Utils.getHostname(u) ?? "").filter(Boolean),
|
||||
);
|
||||
readonly viewOnly = signal<boolean>(this.params.viewOnly ?? false);
|
||||
readonly savedUrlsExpanded = signal<boolean>(false);
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
|
||||
private dialogRef: DialogRef,
|
||||
) {
|
||||
this.currentUrl = Utils.getHostname(params.currentUrl);
|
||||
this.viewOnly = params.viewOnly ?? false;
|
||||
this.savedUrls =
|
||||
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
protected get savedUrlsListClass(): string {
|
||||
return this.savedUrlsExpanded
|
||||
readonly savedUrlsListClass = computed(() =>
|
||||
this.savedUrlsExpanded()
|
||||
? ""
|
||||
: `tw-relative
|
||||
tw-max-h-24
|
||||
tw-overflow-hidden
|
||||
after:tw-pointer-events-none after:tw-content-['']
|
||||
after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0
|
||||
after:tw-h-8 after:tw-bg-gradient-to-t
|
||||
after:tw-from-background after:tw-to-transparent
|
||||
`;
|
||||
: `tw-relative tw-max-h-24 tw-overflow-hidden after:tw-pointer-events-none
|
||||
after:tw-content-[''] after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0
|
||||
after:tw-h-8 after:tw-bg-gradient-to-t after:tw-from-background after:tw-to-transparent`,
|
||||
);
|
||||
|
||||
toggleSavedUrlExpandedState() {
|
||||
this.savedUrlsExpanded.update((v) => !v);
|
||||
}
|
||||
|
||||
protected viewAllSavedUrls() {
|
||||
this.savedUrlsExpanded = true;
|
||||
}
|
||||
|
||||
protected close() {
|
||||
close() {
|
||||
this.dialogRef.close(AutofillConfirmationDialogResult.Canceled);
|
||||
}
|
||||
|
||||
protected autofillAndAddUrl() {
|
||||
autofillAndAddUrl() {
|
||||
this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
|
||||
}
|
||||
|
||||
protected autofillOnly() {
|
||||
autofillOnly() {
|
||||
this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ "autofill" | i18n }}
|
||||
</button>
|
||||
<!-- Autofill confirmation handles both 'autofill' and 'autofill and save' so no need to show both -->
|
||||
@if (!(showAutofillConfirmation$ | async)) {
|
||||
@if (!(autofillConfirmationFlagEnabled$ | async)) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
@@ -52,7 +52,7 @@
|
||||
</a>
|
||||
</ng-container>
|
||||
@if (canArchive$ | async) {
|
||||
<button type="button" bitMenuItem (click)="archive()" *ngIf="canArchive$ | async">
|
||||
<button type="button" bitMenuItem (click)="archive()">
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
@@ -66,11 +67,6 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(),
|
||||
};
|
||||
|
||||
const hasSearchText$ = new BehaviorSubject(false);
|
||||
const vaultPopupItemsService = {
|
||||
hasSearchText$: hasSearchText$.asObservable(),
|
||||
};
|
||||
|
||||
const baseCipher = {
|
||||
id: "cipher-1",
|
||||
login: {
|
||||
@@ -120,7 +116,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
},
|
||||
{
|
||||
provide: VaultPopupItemsService,
|
||||
useValue: vaultPopupItemsService,
|
||||
useValue: mock<VaultPopupItemsService>({}),
|
||||
},
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
@@ -153,7 +149,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
|
||||
});
|
||||
|
||||
it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
|
||||
it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled", async () => {
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
@@ -182,7 +178,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
});
|
||||
|
||||
it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => {
|
||||
// autofill confirmation dialog is not shown when either the feature flag is disabled or search text is not present
|
||||
// autofill confirmation dialog is not shown when either the feature flag is disabled
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
|
||||
await component.doAutofill();
|
||||
@@ -192,9 +188,8 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
|
||||
describe("autofill confirmation dialog", () => {
|
||||
beforeEach(() => {
|
||||
// autofill confirmation dialog is shown when feature flag is enabled and search text is present
|
||||
// autofill confirmation dialog is shown when feature flag is enabled
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Domain);
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||
});
|
||||
@@ -208,7 +203,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
|
||||
});
|
||||
|
||||
it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
|
||||
it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled", async () => {
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
|
||||
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
|
||||
@@ -216,8 +211,8 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
const args = openSpy.mock.calls[0][1];
|
||||
expect(args.data.currentUrl).toBe("https://page.example.com/path");
|
||||
expect(args.data.savedUrls).toEqual([
|
||||
expect(args.data?.currentUrl).toBe("https://page.example.com/path");
|
||||
expect(args.data?.savedUrls).toEqual([
|
||||
"https://one.example.com",
|
||||
"https://two.example.com/a",
|
||||
]);
|
||||
|
||||
@@ -84,10 +84,9 @@ export class ItemMoreOptionsComponent {
|
||||
|
||||
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
|
||||
|
||||
protected showAutofillConfirmation$ = combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation),
|
||||
this.vaultPopupItemsService.hasSearchText$,
|
||||
]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText));
|
||||
protected autofillConfirmationFlagEnabled$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.AutofillConfirmation)
|
||||
.pipe(map((isFeatureFlagEnabled) => isFeatureFlagEnabled));
|
||||
|
||||
protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$;
|
||||
|
||||
@@ -210,7 +209,7 @@ export class ItemMoreOptionsComponent {
|
||||
const cipherHasAllExactMatchLoginUris =
|
||||
uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact);
|
||||
|
||||
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
|
||||
const showAutofillConfirmation = await firstValueFrom(this.autofillConfirmationFlagEnabled$);
|
||||
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
|
||||
|
||||
if (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -76,6 +77,10 @@ describe("VaultHeaderV2Component", () => {
|
||||
{ provide: MessageSender, useValue: mock<MessageSender>() },
|
||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)) },
|
||||
},
|
||||
{
|
||||
provide: VaultPopupItemsService,
|
||||
useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),
|
||||
|
||||
@@ -4,6 +4,5 @@
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
appAutofocus
|
||||
[disabled]="loading$ | async"
|
||||
>
|
||||
</bit-search>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
|
||||
|
||||
import { VaultV2SearchComponent } from "./vault-v2-search.component";
|
||||
|
||||
describe("VaultV2SearchComponent", () => {
|
||||
let component: VaultV2SearchComponent;
|
||||
let fixture: ComponentFixture<VaultV2SearchComponent>;
|
||||
|
||||
const searchText$ = new BehaviorSubject("");
|
||||
const loading$ = new BehaviorSubject(false);
|
||||
const featureFlag$ = new BehaviorSubject(true);
|
||||
const applyFilter = jest.fn();
|
||||
|
||||
const createComponent = () => {
|
||||
fixture = TestBed.createComponent(VaultV2SearchComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
applyFilter.mockClear();
|
||||
featureFlag$.next(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: VaultPopupItemsService,
|
||||
useValue: {
|
||||
searchText$,
|
||||
applyFilter,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: VaultPopupLoadingService,
|
||||
useValue: {
|
||||
loading$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag$: jest.fn(() => featureFlag$),
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it("subscribes to search text from service", () => {
|
||||
createComponent();
|
||||
|
||||
searchText$.next("test search");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.searchText).toBe("test search");
|
||||
});
|
||||
|
||||
describe("debouncing behavior", () => {
|
||||
describe("when feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it("debounces search text changes when not loading", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("should not debounce search text changes when loading", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(0);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("cancels previous debounce when new text is entered", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
component.searchText = "test2";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test2");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("when feature flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(false);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it("debounces search text changes", fakeAsync(() => {
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("ignores loading state and always debounces", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,22 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, NgZone } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs";
|
||||
import {
|
||||
Subject,
|
||||
Subscription,
|
||||
combineLatest,
|
||||
debounce,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
@@ -27,6 +40,7 @@ export class VaultV2SearchComponent {
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||
private configService: ConfigService,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
this.subscribeToLatestSearchText();
|
||||
@@ -48,13 +62,38 @@ export class VaultV2SearchComponent {
|
||||
});
|
||||
}
|
||||
|
||||
subscribeToApplyFilter(): Subscription {
|
||||
return this.searchText$
|
||||
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
||||
.subscribe((data) => {
|
||||
subscribeToApplyFilter(): void {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons)
|
||||
.pipe(
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return this.searchText$.pipe(
|
||||
debounceTime(SearchTextDebounceInterval),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
return combineLatest([this.searchText$, this.loading$]).pipe(
|
||||
debounce(([_, isLoading]) => {
|
||||
// If loading apply immediately to avoid stale searches.
|
||||
// After loading completes, debounce to avoid excessive searches.
|
||||
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
|
||||
return timer(delayTime);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
([prevText, prevLoading], [newText, newLoading]) =>
|
||||
prevText === newText && prevLoading === newLoading,
|
||||
),
|
||||
map(([text, _]) => text),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((text) => {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.ngZone.run(() => {
|
||||
this.vaultPopupItemsService.applyFilter(data);
|
||||
this.vaultPopupItemsService.applyFilter(text);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
4
apps/desktop/desktop_native/Cargo.lock
generated
4
apps/desktop/desktop_native/Cargo.lock
generated
@@ -3660,9 +3660,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
|
||||
@@ -72,7 +72,7 @@ tracing-subscriber = { version = "=0.3.20", features = [
|
||||
"env-filter",
|
||||
"tracing-log",
|
||||
] }
|
||||
typenum = "=1.18.0"
|
||||
typenum = "=1.19.0"
|
||||
uniffi = "=0.28.3"
|
||||
widestring = "=1.2.0"
|
||||
windows = "=0.61.1"
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<auth-vault-timeout-input
|
||||
<bit-session-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
</bit-session-timeout-input>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{
|
||||
|
||||
@@ -9,7 +9,6 @@ import { concatMap, map, pairwise, startWith, switchMap, takeUntil, timeout } fr
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
@@ -55,7 +54,10 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
SessionTimeoutInputComponent,
|
||||
SessionTimeoutSettingsComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
|
||||
import { SetPinComponent } from "../../auth/components/set-pin.component";
|
||||
@@ -95,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutInputComponent,
|
||||
SessionTimeoutSettingsComponent,
|
||||
PermitCipherDetailsPopoverComponent,
|
||||
PremiumBadgeComponent,
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { RotateableKeySetService } from "./rotateable-key-set.service";
|
||||
|
||||
describe("RotateableKeySetService", () => {
|
||||
let testBed!: TestBed;
|
||||
let keyService!: MockProxy<KeyService>;
|
||||
let encryptService!: MockProxy<EncryptService>;
|
||||
let service!: RotateableKeySetService;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: EncryptService, useValue: encryptService },
|
||||
],
|
||||
});
|
||||
service = testBed.inject(RotateableKeySetService);
|
||||
});
|
||||
|
||||
describe("createKeySet", () => {
|
||||
it("should create a new key set", async () => {
|
||||
const externalKey = createSymmetricKey();
|
||||
const userKey = createSymmetricKey();
|
||||
const encryptedUserKey = Symbol();
|
||||
const encryptedPublicKey = Symbol();
|
||||
const encryptedPrivateKey = Symbol();
|
||||
keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey as any]);
|
||||
keyService.getUserKey.mockResolvedValue({ key: userKey.key } as any);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey as any);
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey as any);
|
||||
|
||||
const result = await service.createKeySet(externalKey as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
encryptedUserKey,
|
||||
encryptedPublicKey,
|
||||
encryptedPrivateKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createSymmetricKey() {
|
||||
const key = Utils.fromB64ToArray(
|
||||
"1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ",
|
||||
);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class RotateableKeySetService {
|
||||
private readonly keyService = inject(KeyService);
|
||||
private readonly encryptService = inject(EncryptService);
|
||||
|
||||
/**
|
||||
* Create a new rotateable key set for the current user, using the provided external key.
|
||||
* For more information on rotateable key sets, see {@link RotateableKeySet}
|
||||
*
|
||||
* @param externalKey The `ExternalKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey}
|
||||
* @returns RotateableKeySet containing the current users `UserKey`
|
||||
*/
|
||||
async createKeySet<ExternalKey extends SymmetricCryptoKey>(
|
||||
externalKey: ExternalKey,
|
||||
): Promise<RotateableKeySet<ExternalKey>> {
|
||||
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(externalKey);
|
||||
|
||||
const userKey = await this.keyService.getUserKey();
|
||||
const rawPublicKey = Utils.fromB64ToArray(publicKey);
|
||||
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
userKey,
|
||||
rawPublicKey,
|
||||
);
|
||||
const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
rawPublicKey,
|
||||
userKey,
|
||||
);
|
||||
return new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the current user's `UserKey` and updates the provided `RotateableKeySet` with the new keys.
|
||||
*
|
||||
* @param keySet The current `RotateableKeySet` for the user
|
||||
* @returns The updated `RotateableKeySet` with the new `UserKey`
|
||||
*/
|
||||
async rotateKeySet<ExternalKey extends SymmetricCryptoKey>(
|
||||
keySet: RotateableKeySet<ExternalKey>,
|
||||
oldUserKey: SymmetricCryptoKey,
|
||||
newUserKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<ExternalKey>> {
|
||||
// validate parameters
|
||||
if (!keySet) {
|
||||
throw new Error("failed to rotate key set: keySet is required");
|
||||
}
|
||||
if (!oldUserKey) {
|
||||
throw new Error("failed to rotate key set: oldUserKey is required");
|
||||
}
|
||||
if (!newUserKey) {
|
||||
throw new Error("failed to rotate key set: newUserKey is required");
|
||||
}
|
||||
|
||||
const publicKey = await this.encryptService.unwrapEncapsulationKey(
|
||||
keySet.encryptedPublicKey,
|
||||
oldUserKey,
|
||||
);
|
||||
if (publicKey == null) {
|
||||
throw new Error("failed to rotate key set: could not decrypt public key");
|
||||
}
|
||||
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
publicKey,
|
||||
newUserKey,
|
||||
);
|
||||
const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newUserKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const newRotateableKeySet = new RotateableKeySet<ExternalKey>(
|
||||
newEncryptedUserKey,
|
||||
newEncryptedPublicKey,
|
||||
keySet.encryptedPrivateKey,
|
||||
);
|
||||
|
||||
return newRotateableKeySet;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum";
|
||||
|
||||
@@ -3,23 +3,26 @@
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set";
|
||||
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { WebauthnLoginCredentialPrfStatus } from "../../enums/webauthn-login-credential-prf-status.enum";
|
||||
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
||||
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
|
||||
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
||||
|
||||
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
|
||||
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
|
||||
@@ -32,9 +35,12 @@ describe("WebauthnAdminService", () => {
|
||||
let rotateableKeySetService!: MockProxy<RotateableKeySetService>;
|
||||
let webAuthnLoginPrfKeyService!: MockProxy<WebAuthnLoginPrfKeyServiceAbstraction>;
|
||||
let credentials: MockProxy<CredentialsContainer>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let service!: WebauthnLoginAdminService;
|
||||
|
||||
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||
|
||||
beforeAll(() => {
|
||||
// Polyfill missing class
|
||||
@@ -45,12 +51,14 @@ describe("WebauthnAdminService", () => {
|
||||
userVerificationService = mock<UserVerificationService>();
|
||||
rotateableKeySetService = mock<RotateableKeySetService>();
|
||||
webAuthnLoginPrfKeyService = mock<WebAuthnLoginPrfKeyServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
credentials = mock<CredentialsContainer>();
|
||||
service = new WebauthnLoginAdminService(
|
||||
apiService,
|
||||
userVerificationService,
|
||||
rotateableKeySetService,
|
||||
webAuthnLoginPrfKeyService,
|
||||
keyService,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -58,6 +66,8 @@ describe("WebauthnAdminService", () => {
|
||||
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
|
||||
// Mock the global AuthenticatorAssertionResponse class b/c the class is only available in secure contexts
|
||||
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -124,7 +134,7 @@ describe("WebauthnAdminService", () => {
|
||||
const request = new EnableCredentialEncryptionRequest();
|
||||
request.token = assertionOptions.token;
|
||||
request.deviceResponse = assertionOptions.deviceResponse;
|
||||
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
|
||||
request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString;
|
||||
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
|
||||
|
||||
@@ -135,10 +145,10 @@ describe("WebauthnAdminService", () => {
|
||||
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await service.enableCredentialEncryption(assertionOptions);
|
||||
await service.enableCredentialEncryption(assertionOptions, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey);
|
||||
expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey, mockUserKey);
|
||||
expect(updateCredentialMock).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
@@ -161,7 +171,7 @@ describe("WebauthnAdminService", () => {
|
||||
|
||||
// Act
|
||||
try {
|
||||
await service.enableCredentialEncryption(assertionOptions);
|
||||
await service.enableCredentialEncryption(assertionOptions, mockUserId);
|
||||
} catch (error) {
|
||||
// Assert
|
||||
expect(error).toEqual(new Error("invalid credential"));
|
||||
@@ -170,6 +180,19 @@ describe("WebauthnAdminService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([null, undefined, ""])("should throw an error when userId is %p", async (userId) => {
|
||||
const response = new MockPublicKeyCredential();
|
||||
const assertionOptions: WebAuthnLoginCredentialAssertionView =
|
||||
new WebAuthnLoginCredentialAssertionView(
|
||||
"enable_credential_encryption_test_token",
|
||||
new WebAuthnLoginAssertionResponseRequest(response),
|
||||
{} as PrfKey,
|
||||
);
|
||||
await expect(
|
||||
service.enableCredentialEncryption(assertionOptions, userId as any),
|
||||
).rejects.toThrow("userId is required");
|
||||
});
|
||||
|
||||
it("should throw error when WehAuthnLoginCredentialAssertionView is undefined", async () => {
|
||||
// Arrange
|
||||
const assertionOptions: WebAuthnLoginCredentialAssertionView = undefined;
|
||||
@@ -182,7 +205,7 @@ describe("WebauthnAdminService", () => {
|
||||
|
||||
// Act
|
||||
try {
|
||||
await service.enableCredentialEncryption(assertionOptions);
|
||||
await service.enableCredentialEncryption(assertionOptions, mockUserId);
|
||||
} catch (error) {
|
||||
// Assert
|
||||
expect(error).toEqual(new Error("invalid credential"));
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Injectable, Optional } from "@angular/core";
|
||||
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PrfKeySet } from "@bitwarden/auth/common";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set";
|
||||
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/key-management";
|
||||
import { KeyService, UserKeyRotationDataProvider } from "@bitwarden/key-management";
|
||||
|
||||
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
||||
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
|
||||
import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view";
|
||||
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
||||
|
||||
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
|
||||
import { SaveCredentialRequest } from "./request/save-credential.request";
|
||||
@@ -55,6 +65,7 @@ export class WebauthnLoginAdminService
|
||||
private userVerificationService: UserVerificationService,
|
||||
private rotateableKeySetService: RotateableKeySetService,
|
||||
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
private keyService: KeyService,
|
||||
@Optional() navigatorCredentials?: CredentialsContainer,
|
||||
@Optional() private logService?: LogService,
|
||||
) {
|
||||
@@ -131,10 +142,12 @@ export class WebauthnLoginAdminService
|
||||
* This will trigger the browsers WebAuthn API to generate a PRF-output.
|
||||
*
|
||||
* @param pendingCredential A credential created using `createCredential`.
|
||||
* @param userId The target users id.
|
||||
* @returns A key set that can be saved to the server. Undefined is returned if the credential doesn't support PRF.
|
||||
*/
|
||||
async createKeySet(
|
||||
pendingCredential: PendingWebauthnLoginCredentialView,
|
||||
userId: UserId,
|
||||
): Promise<PrfKeySet | undefined> {
|
||||
const nativeOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
@@ -166,7 +179,8 @@ export class WebauthnLoginAdminService
|
||||
|
||||
const symmetricPrfKey =
|
||||
await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult);
|
||||
return await this.rotateableKeySetService.createKeySet(symmetricPrfKey);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
return await this.rotateableKeySetService.createKeySet(symmetricPrfKey, userKey);
|
||||
} catch (error) {
|
||||
this.logService?.error(error);
|
||||
return undefined;
|
||||
@@ -190,7 +204,7 @@ export class WebauthnLoginAdminService
|
||||
request.token = credential.createOptions.token;
|
||||
request.name = name;
|
||||
request.supportsPrf = credential.supportsPrf;
|
||||
request.encryptedUserKey = prfKeySet?.encryptedUserKey.encryptedString;
|
||||
request.encryptedUserKey = prfKeySet?.encapsulatedDownstreamKey.encryptedString;
|
||||
request.encryptedPublicKey = prfKeySet?.encryptedPublicKey.encryptedString;
|
||||
request.encryptedPrivateKey = prfKeySet?.encryptedPrivateKey.encryptedString;
|
||||
await this.apiService.saveCredential(request);
|
||||
@@ -204,23 +218,31 @@ export class WebauthnLoginAdminService
|
||||
* if there was a problem with the Credential Assertion.
|
||||
*
|
||||
* @param assertionOptions Options received from the server using `getCredentialAssertOptions`.
|
||||
* @param userId The target users id.
|
||||
* @returns void
|
||||
*/
|
||||
async enableCredentialEncryption(
|
||||
assertionOptions: WebAuthnLoginCredentialAssertionView,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
if (assertionOptions === undefined || assertionOptions?.prfKey === undefined) {
|
||||
throw new Error("invalid credential");
|
||||
}
|
||||
if (!userId) {
|
||||
throw new Error("userId is required");
|
||||
}
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
const prfKeySet: PrfKeySet = await this.rotateableKeySetService.createKeySet(
|
||||
assertionOptions.prfKey,
|
||||
userKey,
|
||||
);
|
||||
|
||||
const request = new EnableCredentialEncryptionRequest();
|
||||
request.token = assertionOptions.token;
|
||||
request.deviceResponse = assertionOptions.deviceResponse;
|
||||
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
|
||||
request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString;
|
||||
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
|
||||
await this.apiService.updateCredential(request);
|
||||
@@ -317,7 +339,7 @@ export class WebauthnLoginAdminService
|
||||
const request = new WebauthnRotateCredentialRequest(
|
||||
response.id,
|
||||
rotatedKeyset.encryptedPublicKey,
|
||||
rotatedKeyset.encryptedUserKey,
|
||||
rotatedKeyset.encapsulatedDownstreamKey,
|
||||
);
|
||||
return request;
|
||||
}),
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
TwoFactorAuthSecurityKeyFailedIcon,
|
||||
TwoFactorAuthSecurityKeyIcon,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import { PrfKeySet } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { WebauthnLoginAdminService } from "../../../core";
|
||||
@@ -67,10 +68,10 @@ export class CreateCredentialDialogComponent implements OnInit {
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogRef: DialogRef,
|
||||
private webauthnService: WebauthnLoginAdminService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -146,13 +147,14 @@ export class CreateCredentialDialogComponent implements OnInit {
|
||||
if (this.formGroup.controls.credentialNaming.controls.name.invalid) {
|
||||
return;
|
||||
}
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
let keySet: PrfKeySet | undefined;
|
||||
if (
|
||||
this.pendingCredential.supportsPrf &&
|
||||
this.formGroup.value.credentialNaming.useForEncryption
|
||||
) {
|
||||
keySet = await this.webauthnService.createKeySet(this.pendingCredential);
|
||||
keySet = await this.webauthnService.createKeySet(this.pendingCredential, userId);
|
||||
|
||||
if (keySet === undefined) {
|
||||
this.formGroup.controls.credentialNaming.controls.useForEncryption?.setErrors({
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Subject } from "rxjs";
|
||||
import { firstValueFrom, Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@bitwarden/components";
|
||||
@@ -47,6 +49,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy {
|
||||
private dialogRef: DialogRef,
|
||||
private webauthnService: WebauthnLoginAdminService,
|
||||
private webauthnLoginService: WebAuthnLoginServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -60,6 +63,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy {
|
||||
if (this.credential === undefined) {
|
||||
return;
|
||||
}
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
this.dialogRef.disableClose = true;
|
||||
try {
|
||||
@@ -68,6 +72,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
await this.webauthnService.enableCredentialEncryption(
|
||||
await this.webauthnLoginService.assertCredential(this.credentialOptions),
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 400) {
|
||||
|
||||
@@ -436,7 +436,7 @@ describe("UpgradePaymentService", () => {
|
||||
tier: "families",
|
||||
passwordManager: {
|
||||
additionalStorage: 0,
|
||||
seats: 6,
|
||||
seats: 1,
|
||||
sponsored: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -98,41 +98,37 @@ export class UpgradePaymentService {
|
||||
planDetails: PlanDetails,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<number> {
|
||||
const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
|
||||
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
|
||||
|
||||
let taxClientCall: Promise<TaxAmounts> | null = null;
|
||||
|
||||
if (isFamiliesPlan) {
|
||||
// Currently, only Families plan is supported for organization plans
|
||||
const request: OrganizationSubscriptionPurchase = {
|
||||
tier: "families",
|
||||
cadence: "annually",
|
||||
passwordManager: { seats: 1, additionalStorage: 0, sponsored: false },
|
||||
};
|
||||
|
||||
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
request,
|
||||
billingAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (isPremiumPlan) {
|
||||
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
|
||||
}
|
||||
|
||||
if (taxClientCall === null) {
|
||||
throw new Error("Tax client call is not defined");
|
||||
}
|
||||
|
||||
try {
|
||||
const isOrganizationPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
|
||||
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
|
||||
|
||||
let taxClientCall: Promise<TaxAmounts> | null = null;
|
||||
|
||||
if (isOrganizationPlan) {
|
||||
const seats = this.getPasswordManagerSeats(planDetails);
|
||||
if (seats === 0) {
|
||||
throw new Error("Seats must be greater than 0 for organization plan");
|
||||
}
|
||||
// Currently, only Families plan is supported for organization plans
|
||||
const request: OrganizationSubscriptionPurchase = {
|
||||
tier: "families",
|
||||
cadence: "annually",
|
||||
passwordManager: { seats, additionalStorage: 0, sponsored: false },
|
||||
};
|
||||
|
||||
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
request,
|
||||
billingAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (isPremiumPlan) {
|
||||
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
|
||||
}
|
||||
|
||||
if (taxClientCall === null) {
|
||||
throw new Error("Tax client call is not defined");
|
||||
}
|
||||
|
||||
const preview = await taxClientCall;
|
||||
return preview.tax;
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
this.logService.error("Tax calculation failed:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -37,41 +37,63 @@
|
||||
<dd *ngIf="sub.expiration">{{ sub.expiration | date: "mediumDate" }}</dd>
|
||||
<dd *ngIf="!sub.expiration">{{ "neverExpires" | i18n }}</dd>
|
||||
</dl>
|
||||
<div class="tw-flex tw-w-full" *ngIf="!selfHosted">
|
||||
<div class="tw-w-1/3">
|
||||
<dl>
|
||||
<dt>{{ "status" | i18n }}</dt>
|
||||
<dd>
|
||||
<div class="tw-flex tw-max-w-[1340px] tw-pt-6" *ngIf="!selfHosted">
|
||||
<div class="tw-flex tw-gap-16 tw-justify-between tw-w-full">
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-font-semibold tw-mb-2">{{ "plan" | i18n }}</div>
|
||||
<div>{{ "premiumMembership" | i18n }}</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-font-semibold tw-mb-2">{{ "status" | i18n }}</div>
|
||||
<div>
|
||||
<span class="tw-capitalize">{{ (subscription && subscriptionStatus) || "-" }}</span>
|
||||
<span bitBadge variant="warning" *ngIf="subscriptionMarkedForCancel">{{
|
||||
"pendingCancellation" | i18n
|
||||
}}</span>
|
||||
</dd>
|
||||
<dt>{{ "nextCharge" | i18n }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
nextInvoice
|
||||
? (sub.subscription.periodEndDate | date: "mediumDate") +
|
||||
", " +
|
||||
(nextInvoice.amount | currency: "$")
|
||||
: "-"
|
||||
}}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="tw-w-2/3" *ngIf="subscription">
|
||||
<strong class="!tw-block tw-mb-1">{{ "details" | i18n }}</strong>
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
<tr *ngFor="let i of subscription.items">
|
||||
<td bitCell>
|
||||
{{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @
|
||||
{{ i.amount | currency: "$" }}
|
||||
</td>
|
||||
<td bitCell>{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
<span
|
||||
bitBadge
|
||||
variant="warning"
|
||||
*ngIf="subscriptionMarkedForCancel"
|
||||
class="tw-mt-2 tw-block"
|
||||
>{{ "pendingCancellation" | i18n }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-font-semibold tw-mb-2 tw-text-right">{{ "nextChargeHeader" | i18n }}</div>
|
||||
<div>
|
||||
<ng-container *ngIf="subscription">
|
||||
<ng-container *ngIf="enableDiscountDisplay$ | async as enableDiscount; else noDiscount">
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(discountedSubscriptionAmount | currency: "$")
|
||||
}}
|
||||
</span>
|
||||
<billing-discount-badge
|
||||
[discount]="getDiscountInfo(sub?.customerDiscount)"
|
||||
></billing-discount-badge>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #noDiscount>
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(subscriptionAmount | currency: "$")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<span
|
||||
*ngIf="!subscription"
|
||||
class="tw-block tw-text-right"
|
||||
[attr.aria-label]="'noChargeScheduled' | i18n"
|
||||
>-</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="selfHosted">
|
||||
@@ -90,8 +112,27 @@
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!selfHosted">
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div class="tw-max-w-[1340px]" *ngIf="!selfHosted">
|
||||
<h3 bitTypography="h3" class="tw-mt-8">{{ "storage" | i18n }}</h3>
|
||||
<p bitTypography="body1">
|
||||
{{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }}
|
||||
</p>
|
||||
<bit-progress [barWidth]="storagePercentage" bgColor="success" size="default"></bit-progress>
|
||||
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
|
||||
<div class="tw-mt-3">
|
||||
<div class="tw-flex tw-gap-4">
|
||||
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(true)">
|
||||
{{ "addStorage" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(false)">
|
||||
{{ "removeStorage" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<h3 bitTypography="h3" class="tw-mt-16">{{ "additionalOptions" | i18n }}</h3>
|
||||
<p bitTypography="body1" class="tw-mt-3">{{ "additionalOptionsDesc" | i18n }}</p>
|
||||
<div class="tw-flex tw-gap-4 tw-mt-3">
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
@@ -106,7 +147,6 @@
|
||||
#cancelBtn
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
class="tw-ml-auto"
|
||||
(click)="cancelSubscription()"
|
||||
[appApiAction]="cancelPromise"
|
||||
[disabled]="$any(cancelBtn).loading()"
|
||||
@@ -115,22 +155,5 @@
|
||||
{{ "cancelSubscription" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
<h3 bitTypography="h3" class="tw-mt-16">{{ "storage" | i18n }}</h3>
|
||||
<p bitTypography="body1">
|
||||
{{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }}
|
||||
</p>
|
||||
<bit-progress [barWidth]="storagePercentage" bgColor="success" size="default"></bit-progress>
|
||||
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
|
||||
<div class="tw-mt-3">
|
||||
<div class="tw-flex tw-gap-1">
|
||||
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(true)">
|
||||
{{ "addStorage" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(false)">
|
||||
{{ "removeStorage" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -7,13 +7,17 @@ import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { DiscountInfo } from "@bitwarden/pricing";
|
||||
|
||||
import {
|
||||
AdjustStorageDialogComponent,
|
||||
@@ -42,6 +46,10 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
cancelPromise: Promise<any>;
|
||||
reinstatePromise: Promise<any>;
|
||||
|
||||
protected enableDiscountDisplay$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM23341_Milestone_2,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
@@ -54,6 +62,7 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private toastService: ToastService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.selfHosted = this.platformUtilsService.isSelfHost();
|
||||
}
|
||||
@@ -187,6 +196,28 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
return this.sub != null ? this.sub.upcomingInvoice : null;
|
||||
}
|
||||
|
||||
get subscriptionAmount(): number {
|
||||
if (!this.subscription?.items || this.subscription.items.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.subscription.items.reduce(
|
||||
(sum, item) => sum + (item.amount || 0) * (item.quantity || 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
get discountedSubscriptionAmount(): number {
|
||||
// Use the upcoming invoice amount from the server as it already includes discounts,
|
||||
// taxes, prorations, and all other adjustments. Fall back to subscription amount
|
||||
// if upcoming invoice is not available.
|
||||
if (this.nextInvoice?.amount != null) {
|
||||
return this.nextInvoice.amount;
|
||||
}
|
||||
|
||||
return this.subscriptionAmount;
|
||||
}
|
||||
|
||||
get storagePercentage() {
|
||||
return this.sub != null && this.sub.maxStorageGb
|
||||
? +(100 * (this.sub.storageGb / this.sub.maxStorageGb)).toFixed(2)
|
||||
@@ -217,4 +248,15 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
return this.subscription.status;
|
||||
}
|
||||
}
|
||||
|
||||
getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null {
|
||||
if (!discount) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: discount.active,
|
||||
percentOff: discount.percentOff,
|
||||
amountOff: discount.amountOff,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
import { DiscountBadgeComponent } from "@bitwarden/pricing";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
@@ -28,6 +29,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
|
||||
BannerModule,
|
||||
EnterPaymentMethodComponent,
|
||||
EnterBillingAddressComponent,
|
||||
DiscountBadgeComponent,
|
||||
],
|
||||
declarations: [
|
||||
BillingHistoryComponent,
|
||||
@@ -51,6 +53,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
|
||||
OffboardingSurveyComponent,
|
||||
IndividualSelfHostingLicenseUploaderComponent,
|
||||
OrganizationSelfHostingLicenseUploaderComponent,
|
||||
DiscountBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class BillingSharedModule {}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
|
||||
</span>
|
||||
</bit-callout>
|
||||
<auth-vault-timeout-input
|
||||
<bit-session-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
</bit-session-timeout-input>
|
||||
<ng-container *ngIf="availableVaultTimeoutActions$ | async as availableVaultTimeoutActions">
|
||||
<bit-radio-group
|
||||
formControlName="vaultTimeoutAction"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
@@ -34,6 +33,7 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { SessionTimeoutInputComponent } from "@bitwarden/key-management-ui";
|
||||
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
@@ -52,7 +52,7 @@ import { SharedModule } from "../shared";
|
||||
imports: [
|
||||
SharedModule,
|
||||
HeaderModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutInputComponent,
|
||||
PermitCipherDetailsPopoverComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -3250,9 +3250,18 @@
|
||||
"nextCharge": {
|
||||
"message": "Next charge"
|
||||
},
|
||||
"nextChargeHeader": {
|
||||
"message": "Next Charge"
|
||||
},
|
||||
"plan": {
|
||||
"message": "Plan"
|
||||
},
|
||||
"details": {
|
||||
"message": "Details"
|
||||
},
|
||||
"discount": {
|
||||
"message": "discount"
|
||||
},
|
||||
"downloadLicense": {
|
||||
"message": "Download license"
|
||||
},
|
||||
@@ -12152,5 +12161,11 @@
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirmNoSelectedCriticalApplicationsTitle": {
|
||||
"message": "No critical applications are selected"
|
||||
},
|
||||
"confirmNoSelectedCriticalApplicationsDesc": {
|
||||
"message": "Are you sure you want to continue?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
createNewSummaryData,
|
||||
flattenMemberDetails,
|
||||
getTrimmedCipherUris,
|
||||
getUniqueMembers,
|
||||
} from "../../helpers";
|
||||
import {
|
||||
ApplicationHealthReportDetailEnriched,
|
||||
@@ -234,6 +235,7 @@ export class RiskInsightsOrchestratorService {
|
||||
const updatedSummaryData = this.reportService.getApplicationsSummary(
|
||||
report!.reportData,
|
||||
updatedApplicationData,
|
||||
report!.summaryData.totalMemberCount,
|
||||
);
|
||||
|
||||
// Used for creating metrics with updated application data
|
||||
@@ -366,6 +368,7 @@ export class RiskInsightsOrchestratorService {
|
||||
const updatedSummaryData = this.reportService.getApplicationsSummary(
|
||||
report!.reportData,
|
||||
updatedApplicationData,
|
||||
report!.summaryData.totalMemberCount,
|
||||
);
|
||||
|
||||
// Used for creating metrics with updated application data
|
||||
@@ -502,6 +505,7 @@ export class RiskInsightsOrchestratorService {
|
||||
const updatedSummaryData = this.reportService.getApplicationsSummary(
|
||||
report!.reportData,
|
||||
updatedApplicationData,
|
||||
report!.summaryData.totalMemberCount,
|
||||
);
|
||||
// Used for creating metrics with updated application data
|
||||
const manualEnrichedApplications = report!.reportData.map(
|
||||
@@ -656,19 +660,30 @@ export class RiskInsightsOrchestratorService {
|
||||
switchMap(([ciphers, memberCiphers]) => {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Analyzing password health");
|
||||
this._reportProgressSubject.next(ReportProgress.AnalyzingPasswords);
|
||||
return this._getCipherHealth(ciphers ?? [], memberCiphers);
|
||||
return forkJoin({
|
||||
memberDetails: of(memberCiphers),
|
||||
cipherHealthReports: this._getCipherHealth(ciphers ?? [], memberCiphers),
|
||||
}).pipe(
|
||||
map(({ memberDetails, cipherHealthReports }) => {
|
||||
const uniqueMembers = getUniqueMembers(memberDetails);
|
||||
const totalMemberCount = uniqueMembers.length;
|
||||
|
||||
return { cipherHealthReports, totalMemberCount };
|
||||
}),
|
||||
);
|
||||
}),
|
||||
map((cipherHealthReports) => {
|
||||
map(({ cipherHealthReports, totalMemberCount }) => {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Calculating risk scores");
|
||||
this._reportProgressSubject.next(ReportProgress.CalculatingRisks);
|
||||
return this.reportService.generateApplicationsReport(cipherHealthReports);
|
||||
const report = this.reportService.generateApplicationsReport(cipherHealthReports);
|
||||
return { report, totalMemberCount };
|
||||
}),
|
||||
tap(() => {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Generating report data");
|
||||
this._reportProgressSubject.next(ReportProgress.GeneratingReport);
|
||||
}),
|
||||
withLatestFrom(this.rawReportData$),
|
||||
map(([report, previousReport]) => {
|
||||
map(([{ report, totalMemberCount }, previousReport]) => {
|
||||
// Update the application data
|
||||
const updatedApplicationData = this.reportService.getOrganizationApplications(
|
||||
report,
|
||||
@@ -688,6 +703,7 @@ export class RiskInsightsOrchestratorService {
|
||||
const updatedSummary = this.reportService.getApplicationsSummary(
|
||||
report,
|
||||
updatedApplicationData,
|
||||
totalMemberCount,
|
||||
);
|
||||
// For now, merge the report with the critical marking flag to make the enriched type
|
||||
// We don't care about the individual ciphers in this instance
|
||||
@@ -964,6 +980,7 @@ export class RiskInsightsOrchestratorService {
|
||||
const summary = this.reportService.getApplicationsSummary(
|
||||
criticalApplications,
|
||||
enrichedReports.applicationData,
|
||||
enrichedReports.summaryData.totalMemberCount,
|
||||
);
|
||||
return {
|
||||
...enrichedReports,
|
||||
|
||||
@@ -83,8 +83,8 @@ export class RiskInsightsReportService {
|
||||
getApplicationsSummary(
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
applicationData: OrganizationReportApplication[],
|
||||
totalMemberCount: number,
|
||||
): OrganizationReportSummary {
|
||||
const totalUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.memberDetails));
|
||||
const atRiskUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.atRiskMemberDetails));
|
||||
|
||||
const criticalReports = this.filterApplicationsByCritical(reports, applicationData);
|
||||
@@ -94,7 +94,7 @@ export class RiskInsightsReportService {
|
||||
);
|
||||
|
||||
return {
|
||||
totalMemberCount: totalUniqueMembers.length,
|
||||
totalMemberCount: totalMemberCount,
|
||||
totalAtRiskMemberCount: atRiskUniqueMembers.length,
|
||||
totalApplicationCount: reports.length,
|
||||
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<div class="tw-items-baseline tw-gap-2">
|
||||
<span bitTypography="body2">{{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }}</span>
|
||||
<span bitTypography="body2">{{ "newPasswordsAtRisk" | i18n: unassignedCipherIds() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
|
||||
@@ -66,16 +66,13 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
readonly completedTasksCount = computed(
|
||||
() => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length,
|
||||
);
|
||||
readonly uncompletedTasksCount = computed(
|
||||
() => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length,
|
||||
);
|
||||
readonly completedTasksPercent = computed(() => {
|
||||
const total = this.tasksCount();
|
||||
// Account for case where there are no tasks to avoid NaN
|
||||
return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0;
|
||||
});
|
||||
|
||||
readonly atRiskPasswordCount = computed<number>(() => {
|
||||
readonly unassignedCipherIds = computed<number>(() => {
|
||||
const atRiskIds = this._atRiskCipherIds();
|
||||
const tasks = this._tasks();
|
||||
|
||||
@@ -83,12 +80,20 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
return atRiskIds.length;
|
||||
}
|
||||
|
||||
const assignedIdSet = new Set(tasks.map((task) => task.cipherId));
|
||||
const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending);
|
||||
const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId));
|
||||
const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id));
|
||||
|
||||
return unassignedIds.length;
|
||||
});
|
||||
|
||||
readonly atRiskPasswordCount = computed<number>(() => {
|
||||
const atRiskIds = this._atRiskCipherIds();
|
||||
const atRiskIdsSet = new Set(atRiskIds);
|
||||
|
||||
return atRiskIdsSet.size;
|
||||
});
|
||||
|
||||
readonly currentView = computed<PasswordChangeView>(() => {
|
||||
if (!this._hasCriticalApplications()) {
|
||||
return PasswordChangeView.EMPTY;
|
||||
@@ -96,7 +101,7 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
if (this.tasksCount() === 0) {
|
||||
return PasswordChangeView.NO_TASKS_ASSIGNED;
|
||||
}
|
||||
if (this.atRiskPasswordCount() > 0) {
|
||||
if (this.unassignedCipherIds() > 0) {
|
||||
return PasswordChangeView.NEW_TASKS_AVAILABLE;
|
||||
}
|
||||
return PasswordChangeView.PROGRESS;
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
|
||||
@if (currentView() === DialogView.AssignTasks) {
|
||||
<dirt-assign-tasks-view
|
||||
[criticalApplicationsCount]="atRiskCriticalApplicationsCount()"
|
||||
[totalApplicationsCount]="totalCriticalApplicationsCount()"
|
||||
[criticalApplicationsCount]="newAtRiskCriticalApplications().length"
|
||||
[totalApplicationsCount]="newCriticalApplications().length"
|
||||
[atRiskCriticalMembersCount]="atRiskCriticalMembersCount()"
|
||||
>
|
||||
</dirt-assign-tasks-view>
|
||||
|
||||
@@ -2,12 +2,15 @@ import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
Inject,
|
||||
inject,
|
||||
Injector,
|
||||
Signal,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { from, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
@@ -17,7 +20,8 @@ import {
|
||||
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks";
|
||||
import {
|
||||
ButtonModule,
|
||||
DIALOG_DATA,
|
||||
@@ -70,9 +74,9 @@ export type NewApplicationsDialogResultType =
|
||||
(typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType];
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "dirt-new-applications-dialog",
|
||||
templateUrl: "./new-applications-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonModule,
|
||||
@@ -95,10 +99,41 @@ export class NewApplicationsDialogComponent {
|
||||
// Applications selected to save as critical applications
|
||||
protected readonly selectedApplications = signal<Set<string>>(new Set());
|
||||
|
||||
// Assign tasks variables
|
||||
readonly atRiskCriticalApplicationsCount = signal<number>(0);
|
||||
readonly totalCriticalApplicationsCount = signal<number>(0);
|
||||
readonly atRiskCriticalMembersCount = signal<number>(0);
|
||||
// Used to determine if there are unassigned at-risk cipher IDs
|
||||
private readonly _tasks!: Signal<SecurityTask[]>;
|
||||
|
||||
// Computed properties for selected applications
|
||||
protected readonly newCriticalApplications = computed(() => {
|
||||
return this.dialogParams.newApplications.filter((newApp) =>
|
||||
this.selectedApplications().has(newApp.applicationName),
|
||||
);
|
||||
});
|
||||
|
||||
// New at risk critical applications
|
||||
protected readonly newAtRiskCriticalApplications = computed(() => {
|
||||
return this.newCriticalApplications().filter((app) => app.atRiskPasswordCount > 0);
|
||||
});
|
||||
|
||||
// Count of unique members with at-risk passwords in newly marked critical applications
|
||||
protected readonly atRiskCriticalMembersCount = computed(() => {
|
||||
return getUniqueMembers(this.newCriticalApplications().flatMap((x) => x.atRiskMemberDetails))
|
||||
.length;
|
||||
});
|
||||
|
||||
protected readonly newUnassignedAtRiskCipherIds = computed<CipherId[]>(() => {
|
||||
const newAtRiskCipherIds = this.newCriticalApplications().flatMap((app) => app.atRiskCipherIds);
|
||||
const tasks = this._tasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return newAtRiskCipherIds;
|
||||
}
|
||||
|
||||
const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending);
|
||||
const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId));
|
||||
const unassignedIds = newAtRiskCipherIds.filter((id) => !assignedIdSet.has(id));
|
||||
return unassignedIds;
|
||||
});
|
||||
|
||||
readonly saving = signal<boolean>(false);
|
||||
|
||||
// Loading states
|
||||
@@ -106,13 +141,21 @@ export class NewApplicationsDialogComponent {
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData,
|
||||
private dialogRef: DialogRef<NewApplicationsDialogResultType>,
|
||||
private dataService: RiskInsightsDataService,
|
||||
private toastService: ToastService,
|
||||
private dialogRef: DialogRef<NewApplicationsDialogResultType>,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
||||
private injector: Injector,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
private securityTasksService: AccessIntelligenceSecurityTasksService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
// Setup the _tasks signal by manually passing in the injector
|
||||
this._tasks = toSignal(this.securityTasksService.tasks$, {
|
||||
initialValue: [],
|
||||
injector: this.injector,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the new applications dialog
|
||||
@@ -170,53 +213,57 @@ export class NewApplicationsDialogComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handleMarkAsCritical() {
|
||||
if (this.markingAsCritical() || this.saving()) {
|
||||
return; // Prevent action if already processing
|
||||
// Checks if there are selected applications and proceeds to assign tasks
|
||||
async handleMarkAsCritical() {
|
||||
if (this.selectedApplications().size === 0) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "confirmNoSelectedCriticalApplicationsTitle" },
|
||||
content: { key: "confirmNoSelectedCriticalApplicationsDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.markingAsCritical.set(true);
|
||||
|
||||
const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) =>
|
||||
this.selectedApplications().has(newApp.applicationName),
|
||||
);
|
||||
|
||||
// Count only critical applications that have at-risk passwords
|
||||
const atRiskCriticalApplicationsCount = onlyNewCriticalApplications.filter(
|
||||
(app) => app.atRiskPasswordCount > 0,
|
||||
).length;
|
||||
this.atRiskCriticalApplicationsCount.set(atRiskCriticalApplicationsCount);
|
||||
|
||||
// Total number of selected critical applications
|
||||
this.totalCriticalApplicationsCount.set(onlyNewCriticalApplications.length);
|
||||
|
||||
const atRiskCriticalMembersCount = getUniqueMembers(
|
||||
onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails),
|
||||
).length;
|
||||
this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount);
|
||||
|
||||
this.currentView.set(DialogView.AssignTasks);
|
||||
this.markingAsCritical.set(false);
|
||||
// Skip the assign tasks view if there are no new unassigned at-risk cipher IDs
|
||||
if (this.newUnassignedAtRiskCipherIds().length === 0) {
|
||||
this.handleAssignTasks();
|
||||
} else {
|
||||
this.currentView.set(DialogView.AssignTasks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the assign tasks button click
|
||||
*/
|
||||
// Saves the application review and assigns tasks for unassigned at-risk ciphers
|
||||
protected handleAssignTasks() {
|
||||
if (this.saving()) {
|
||||
return; // Prevent double-click
|
||||
}
|
||||
this.saving.set(true);
|
||||
|
||||
const reviewedDate = new Date();
|
||||
const updatedApplications = this.dialogParams.newApplications.map((app) => {
|
||||
const isCritical = this.selectedApplications().has(app.applicationName);
|
||||
return {
|
||||
applicationName: app.applicationName,
|
||||
isCritical,
|
||||
reviewedDate,
|
||||
};
|
||||
});
|
||||
|
||||
// Save the application review dates and critical markings
|
||||
this.dataService.criticalApplicationAtRiskCipherIds$
|
||||
this.dataService
|
||||
.saveApplicationReviewStatus(updatedApplications)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
|
||||
take(1), // Handle unsubscribe for one off operation
|
||||
switchMap((criticalApplicationAtRiskCipherIds) => {
|
||||
take(1),
|
||||
switchMap(() => {
|
||||
// Assign password change tasks for unassigned at-risk ciphers for critical applications
|
||||
return from(
|
||||
this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications(
|
||||
this.securityTasksService.requestPasswordChangeForCriticalApplications(
|
||||
this.dialogParams.organizationId,
|
||||
criticalApplicationAtRiskCipherIds,
|
||||
this.newUnassignedAtRiskCipherIds(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
type="button"
|
||||
class="tw-flex-1"
|
||||
tabindex="0"
|
||||
(click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')"
|
||||
(click)="dataService.setDrawerForCriticalAtRiskMembers('criticalAppsAtRiskMembers')"
|
||||
>
|
||||
<dirt-card
|
||||
#criticalAppsAtRiskMembers
|
||||
@@ -40,7 +40,7 @@
|
||||
type="button"
|
||||
class="tw-flex-1"
|
||||
tabindex="0"
|
||||
(click)="dataService.setDrawerForOrgAtRiskApps('criticalAppsAtRiskApplications')"
|
||||
(click)="dataService.setDrawerForCriticalAtRiskApps('criticalAppsAtRiskApplications')"
|
||||
>
|
||||
<dirt-card
|
||||
#criticalAppsAtRiskApplications
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-header> </app-header>
|
||||
|
||||
<ng-container>
|
||||
@let status = dataService.reportStatus$ | async;
|
||||
@let hasCiphers = dataService.hasCiphers$ | async;
|
||||
@@ -8,7 +10,6 @@
|
||||
} @else {
|
||||
<!-- Check final states after initial calls have been completed -->
|
||||
@if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {
|
||||
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
|
||||
<!-- Show empty state only when feature flag is enabled and there's no report data -->
|
||||
<div @fadeIn class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
|
||||
@if (!hasCiphers) {
|
||||
@@ -39,7 +40,6 @@
|
||||
<!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) -->
|
||||
<div @fadeIn class="tw-min-h-screen tw-flex tw-flex-col">
|
||||
<div>
|
||||
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
|
||||
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
|
||||
{{ "reviewAtRiskPasswords" | i18n }}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
}}</span>
|
||||
|
||||
@if (drawerDetails.atRiskMemberDetails?.length > 0) {
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
class="tw-my-4 tw-font-bold tw-block"
|
||||
(click)="downloadAtRiskMembers()"
|
||||
>
|
||||
<i class="bwi bwi-download tw-mr-1"></i>
|
||||
{{ "downloadCSV" | i18n }}
|
||||
</button>
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
@@ -77,6 +86,15 @@
|
||||
}}</span>
|
||||
@if (drawerDetails.atRiskAppDetails?.length > 0) {
|
||||
<ng-container>
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
class="tw-my-4 tw-font-bold tw-block"
|
||||
(click)="downloadAtRiskApplications()"
|
||||
>
|
||||
<i class="bwi bwi-download tw-mr-1"></i>
|
||||
{{ "downloadCSV" | i18n }}
|
||||
</button>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "application" | i18n }}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DIALOG_DATA } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { RiskInsightsDrawerDialogComponent } from "./risk-insights-drawer-dialog.component";
|
||||
@@ -48,6 +50,8 @@ describe("RiskInsightsDrawerDialogComponent", () => {
|
||||
let component: RiskInsightsDrawerDialogComponent;
|
||||
let fixture: ComponentFixture<RiskInsightsDrawerDialogComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockFileDownloadService = mock<FileDownloadService>();
|
||||
const mocklogService = mock<LogService>();
|
||||
const drawerDetails: DrawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
@@ -56,6 +60,7 @@ describe("RiskInsightsDrawerDialogComponent", () => {
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -64,6 +69,8 @@ describe("RiskInsightsDrawerDialogComponent", () => {
|
||||
{ provide: DIALOG_DATA, useValue: drawerDetails },
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
{ provide: LogService, useValue: mocklogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -93,4 +100,181 @@ describe("RiskInsightsDrawerDialogComponent", () => {
|
||||
expect(component.isActiveDrawerType(DrawerType.AppAtRiskMembers)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
describe("downloadAtRiskMembers", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should download CSV when drawer is open with correct type and has data", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails: [
|
||||
{ email: "user@example.com", atRiskPasswordCount: 5 },
|
||||
{ email: "admin@example.com", atRiskPasswordCount: 3 },
|
||||
],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await component.downloadAtRiskMembers();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("at-risk-members"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download when drawer is closed", async () => {
|
||||
component.drawerDetails = {
|
||||
open: false,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
await component.downloadAtRiskMembers();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when activeDrawerType is incorrect", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
await component.downloadAtRiskMembers();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when atRiskMemberDetails is null", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
await component.downloadAtRiskMembers();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when atRiskMemberDetails is empty array", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
await component.downloadAtRiskMembers();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadAtRiskApplications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should download CSV when drawer is open with correct type and has data", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: [
|
||||
{ applicationName: "App1", atRiskPasswordCount: 10 },
|
||||
{ applicationName: "App2", atRiskPasswordCount: 7 },
|
||||
],
|
||||
};
|
||||
|
||||
await component.downloadAtRiskApplications();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("at-risk-applications"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download when drawer is closed", async () => {
|
||||
component.drawerDetails = {
|
||||
open: false,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }],
|
||||
};
|
||||
|
||||
await component.downloadAtRiskApplications();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when activeDrawerType is incorrect", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }],
|
||||
};
|
||||
|
||||
await component.downloadAtRiskApplications();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when atRiskAppDetails is null", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
};
|
||||
|
||||
await component.downloadAtRiskApplications();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not download when atRiskAppDetails is empty array", async () => {
|
||||
component.drawerDetails = {
|
||||
open: true,
|
||||
invokerId: "test-invoker",
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: [],
|
||||
};
|
||||
|
||||
await component.downloadAtRiskApplications();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Component, ChangeDetectionStrategy, Inject } from "@angular/core";
|
||||
|
||||
import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DIALOG_DATA } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||
import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@Component({
|
||||
@@ -10,7 +15,12 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RiskInsightsDrawerDialogComponent {
|
||||
constructor(@Inject(DIALOG_DATA) public drawerDetails: DrawerDetails) {}
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) public drawerDetails: DrawerDetails,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
// Get a list of drawer types
|
||||
get drawerTypes(): typeof DrawerType {
|
||||
@@ -20,4 +30,62 @@ export class RiskInsightsDrawerDialogComponent {
|
||||
isActiveDrawerType(type: DrawerType): boolean {
|
||||
return this.drawerDetails.activeDrawerType === type;
|
||||
}
|
||||
|
||||
/**
|
||||
* downloads at risk members as CSV
|
||||
*/
|
||||
downloadAtRiskMembers() {
|
||||
try {
|
||||
// Validate drawer is open and showing the correct drawer type
|
||||
if (
|
||||
!this.drawerDetails.open ||
|
||||
this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskMembers ||
|
||||
!this.drawerDetails.atRiskMemberDetails ||
|
||||
this.drawerDetails.atRiskMemberDetails.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: ExportHelper.getFileName("at-risk-members"),
|
||||
blobData: exportToCSV(this.drawerDetails.atRiskMemberDetails, {
|
||||
email: this.i18nService.t("email"),
|
||||
atRiskPasswordCount: this.i18nService.t("atRiskPasswords"),
|
||||
}),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
this.logService.error("Failed to download at-risk members", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* downloads at risk applications as CSV
|
||||
*/
|
||||
downloadAtRiskApplications() {
|
||||
try {
|
||||
// Validate drawer is open and showing the correct drawer type
|
||||
if (
|
||||
!this.drawerDetails.open ||
|
||||
this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskApps ||
|
||||
!this.drawerDetails.atRiskAppDetails ||
|
||||
this.drawerDetails.atRiskAppDetails.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: ExportHelper.getFileName("at-risk-applications"),
|
||||
blobData: exportToCSV(this.drawerDetails.atRiskAppDetails, {
|
||||
applicationName: this.i18nService.t("application"),
|
||||
atRiskPasswordCount: this.i18nService.t("atRiskPasswords"),
|
||||
}),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
this.logService.error("Failed to download at-risk applications", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
if (userKey == null) {
|
||||
masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey);
|
||||
} else {
|
||||
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
|
||||
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
masterKey,
|
||||
userKey,
|
||||
);
|
||||
}
|
||||
|
||||
return masterKeyEncryptedUserKey;
|
||||
|
||||
@@ -181,7 +181,9 @@ import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kd
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
|
||||
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
||||
import { DefaultRotateableKeySetService } from "@bitwarden/common/key-management/keys/services/default-rotateable-key-set.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
@@ -1737,6 +1739,11 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RotateableKeySetService,
|
||||
useClass: DefaultRotateableKeySetService,
|
||||
deps: [KeyService, EncryptService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NewDeviceVerificationComponentService,
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
|
||||
@@ -43,9 +43,6 @@ export * from "./user-verification/user-verification-dialog.component";
|
||||
export * from "./user-verification/user-verification-dialog.types";
|
||||
export * from "./user-verification/user-verification-form-input.component";
|
||||
|
||||
// vault timeout
|
||||
export * from "./vault-timeout-input/vault-timeout-input.component";
|
||||
|
||||
// sso
|
||||
export * from "./sso/sso.component";
|
||||
export * from "./sso/sso-component.service";
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./rotateable-key-set";
|
||||
export * from "./login-credentials";
|
||||
export * from "./user-decryption-options";
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PrfKey } from "@bitwarden/common/types/key";
|
||||
|
||||
declare const tag: unique symbol;
|
||||
|
||||
/**
|
||||
* A set of keys where a `UserKey` is protected by an encrypted public/private key-pair.
|
||||
* The `UserKey` is used to encrypt/decrypt data, while the public/private key-pair is
|
||||
* used to rotate the `UserKey`.
|
||||
*
|
||||
* The `PrivateKey` is protected by an `ExternalKey`, such as a `DeviceKey`, or `PrfKey`,
|
||||
* and the `PublicKey` is protected by the `UserKey`. This setup allows:
|
||||
*
|
||||
* - Access to `UserKey` by knowing the `ExternalKey`
|
||||
* - Rotation to a `NewUserKey` by knowing the current `UserKey`,
|
||||
* without needing access to the `ExternalKey`
|
||||
*/
|
||||
export class RotateableKeySet<ExternalKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
|
||||
private readonly [tag]: ExternalKey;
|
||||
|
||||
constructor(
|
||||
/** PublicKey encrypted UserKey */
|
||||
readonly encryptedUserKey: EncString,
|
||||
|
||||
/** UserKey encrypted PublicKey */
|
||||
readonly encryptedPublicKey: EncString,
|
||||
|
||||
/** ExternalKey encrypted PrivateKey */
|
||||
readonly encryptedPrivateKey?: EncString,
|
||||
) {}
|
||||
}
|
||||
|
||||
export type PrfKeySet = RotateableKeySet<PrfKey>;
|
||||
@@ -11,7 +11,7 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction {
|
||||
|
||||
/**
|
||||
* Create a symmetric key from the PRF-output by stretching it.
|
||||
* This should be used as `ExternalKey` with `RotateableKeySet`.
|
||||
* This should be used as `UpstreamKey` with `RotateableKeySet`.
|
||||
*/
|
||||
abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { RotateableKeySet } from "../../../../../auth/src/common/models";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
|
||||
|
||||
export class WebauthnRotateCredentialRequest {
|
||||
id: string;
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class ProtectedDeviceResponse extends BaseResponse {
|
||||
|
||||
@@ -40,6 +40,7 @@ export class BillingCustomerDiscount extends BaseResponse {
|
||||
id: string;
|
||||
active: boolean;
|
||||
percentOff?: number;
|
||||
amountOff?: number;
|
||||
appliesTo: string[];
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -47,6 +48,7 @@ export class BillingCustomerDiscount extends BaseResponse {
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.active = this.getResponseProperty("Active");
|
||||
this.percentOff = this.getResponseProperty("PercentOff");
|
||||
this.appliesTo = this.getResponseProperty("AppliesTo");
|
||||
this.amountOff = this.getResponseProperty("AmountOff");
|
||||
this.appliesTo = this.getResponseProperty("AppliesTo") || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
import { BillingCustomerDiscount } from "./organization-subscription.response";
|
||||
|
||||
export class SubscriptionResponse extends BaseResponse {
|
||||
storageName: string;
|
||||
storageGb: number;
|
||||
maxStorageGb: number;
|
||||
subscription: BillingSubscriptionResponse;
|
||||
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
|
||||
customerDiscount: BillingCustomerDiscount;
|
||||
license: any;
|
||||
expiration: string;
|
||||
|
||||
@@ -20,11 +23,14 @@ export class SubscriptionResponse extends BaseResponse {
|
||||
this.expiration = this.getResponseProperty("Expiration");
|
||||
const subscription = this.getResponseProperty("Subscription");
|
||||
const upcomingInvoice = this.getResponseProperty("UpcomingInvoice");
|
||||
const customerDiscount = this.getResponseProperty("CustomerDiscount");
|
||||
this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription);
|
||||
this.upcomingInvoice =
|
||||
upcomingInvoice == null
|
||||
? null
|
||||
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
|
||||
this.customerDiscount =
|
||||
customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum FeatureFlag {
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -129,6 +130,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -33,6 +33,7 @@ import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../../keys/models/rotateable-key-set";
|
||||
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
|
||||
|
||||
/** Uses disk storage so that the device key can persist after log out and tab removal. */
|
||||
@@ -145,7 +146,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
|
||||
// Attempt to get user key
|
||||
const userKey: UserKey = await this.keyService.getUserKey(userId);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
// If user key is not found, throw error
|
||||
if (!userKey) {
|
||||
@@ -240,7 +241,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
|
||||
const request = new OtherDeviceKeysUpdateRequest();
|
||||
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encapsulatedDownstreamKey.encryptedString;
|
||||
request.deviceId = device.id;
|
||||
return request;
|
||||
})
|
||||
|
||||
@@ -366,7 +366,6 @@ describe("deviceTrustService", () => {
|
||||
|
||||
let makeDeviceKeySpy: jest.SpyInstance;
|
||||
let rsaGenerateKeyPairSpy: jest.SpyInstance;
|
||||
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
|
||||
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
|
||||
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
|
||||
@@ -402,6 +401,8 @@ describe("deviceTrustService", () => {
|
||||
"mockDeviceKeyEncryptedDevicePrivateKey",
|
||||
);
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
makeDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustService as any, "makeDeviceKey")
|
||||
@@ -411,10 +412,6 @@ describe("deviceTrustService", () => {
|
||||
.spyOn(cryptoFunctionService, "rsaGenerateKeyPair")
|
||||
.mockResolvedValue(mockDeviceRsaKeyPair);
|
||||
|
||||
cryptoSvcGetUserKeySpy = jest
|
||||
.spyOn(keyService, "getUserKey")
|
||||
.mockResolvedValue(mockUserKey);
|
||||
|
||||
cryptoSvcRsaEncryptSpy = jest
|
||||
.spyOn(encryptService, "encapsulateKeyUnsigned")
|
||||
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
|
||||
@@ -448,7 +445,7 @@ describe("deviceTrustService", () => {
|
||||
|
||||
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.userKey$).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -473,18 +470,13 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
it("throws specific error if user key is not found", async () => {
|
||||
// setup the spy to return null
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
|
||||
keyService.userKey$.mockReturnValueOnce(of(null));
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
|
||||
// reset the spy
|
||||
cryptoSvcGetUserKeySpy.mockReset();
|
||||
|
||||
// setup the spy to return undefined
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
|
||||
keyService.userKey$.mockReturnValueOnce(of(undefined));
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
@@ -502,11 +494,6 @@ describe("deviceTrustService", () => {
|
||||
spy: () => rsaGenerateKeyPairSpy,
|
||||
errorText: "rsaGenerateKeyPair error",
|
||||
},
|
||||
{
|
||||
method: "getUserKey",
|
||||
spy: () => cryptoSvcGetUserKeySpy,
|
||||
errorText: "getUserKey error",
|
||||
},
|
||||
{
|
||||
method: "rsaEncrypt",
|
||||
spy: () => cryptoSvcRsaEncryptSpy,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { PrfKey } from "../../../types/key";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
declare const tag: unique symbol;
|
||||
|
||||
/**
|
||||
* A set of keys where a symmetric `DownstreamKey` is protected by an encrypted public/private key-pair.
|
||||
* The `DownstreamKey` is used to encrypt/decrypt data, while the public/private key-pair is
|
||||
* used to rotate the `DownstreamKey`.
|
||||
*
|
||||
* The `PrivateKey` is protected by an `UpstreamKey`, such as a `DeviceKey`, or `PrfKey`,
|
||||
* and the `PublicKey` is protected by the `DownstreamKey`. This setup allows:
|
||||
*
|
||||
* - Access to `DownstreamKey` by knowing the `UpstreamKey`
|
||||
* - Rotation to a new `DownstreamKey` by knowing the current `DownstreamKey`,
|
||||
* without needing access to the `UpstreamKey`
|
||||
*/
|
||||
export class RotateableKeySet<UpstreamKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
|
||||
private readonly [tag]!: UpstreamKey;
|
||||
|
||||
constructor(
|
||||
/** `DownstreamKey` protected by publicKey */
|
||||
readonly encapsulatedDownstreamKey: EncString,
|
||||
|
||||
/** DownstreamKey encrypted PublicKey */
|
||||
readonly encryptedPublicKey: EncString,
|
||||
|
||||
/** UpstreamKey encrypted PrivateKey */
|
||||
readonly encryptedPrivateKey?: EncString,
|
||||
) {}
|
||||
}
|
||||
|
||||
export type PrfKeySet = RotateableKeySet<PrfKey>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { RotateableKeySet } from "../../models/rotateable-key-set";
|
||||
|
||||
export abstract class RotateableKeySetService {
|
||||
/**
|
||||
* Create a new rotatable key set for the provided downstreamKey, using the provided upstream key.
|
||||
* For more information on rotatable key sets, see {@link RotateableKeySet}
|
||||
* @param upstreamKey The `UpstreamKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey}
|
||||
* @param downstreamKey The symmetric key to be contained within the `RotateableKeySet`.
|
||||
* @returns RotateableKeySet containing the provided symmetric downstreamKey.
|
||||
*/
|
||||
abstract createKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
upstreamKey: UpstreamKey,
|
||||
downstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>>;
|
||||
|
||||
/**
|
||||
* Rotates the provided `RotateableKeySet` with the new key.
|
||||
*
|
||||
* @param keySet The current `RotateableKeySet` to be rotated.
|
||||
* @param oldDownstreamKey The current downstreamKey used to decrypt the `PublicKey`.
|
||||
* @param newDownstreamKey The new downstreamKey to encrypt the `PublicKey`.
|
||||
* @returns The updated `RotateableKeySet` that contains the new downstreamKey.
|
||||
*/
|
||||
abstract rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
keySet: RotateableKeySet<UpstreamKey>,
|
||||
oldDownstreamKey: SymmetricCryptoKey,
|
||||
newDownstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>>;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../models/rotateable-key-set";
|
||||
|
||||
import { DefaultRotateableKeySetService } from "./default-rotateable-key-set.service";
|
||||
|
||||
describe("DefaultRotateableKeySetService", () => {
|
||||
let keyService!: MockProxy<KeyService>;
|
||||
let encryptService!: MockProxy<EncryptService>;
|
||||
let service!: DefaultRotateableKeySetService;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
service = new DefaultRotateableKeySetService(keyService, encryptService);
|
||||
});
|
||||
|
||||
describe("createKeySet", () => {
|
||||
test.each([null, undefined])(
|
||||
"throws error when downstreamKey parameter is %s",
|
||||
async (downstreamKey) => {
|
||||
const externalKey = createSymmetricKey();
|
||||
await expect(service.createKeySet(externalKey, downstreamKey as any)).rejects.toThrow(
|
||||
"failed to create key set: downstreamKey is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null, undefined])(
|
||||
"throws error when upstreamKey parameter is %s",
|
||||
async (upstreamKey) => {
|
||||
const userKey = createSymmetricKey();
|
||||
await expect(service.createKeySet(upstreamKey as any, userKey)).rejects.toThrow(
|
||||
"failed to create key set: upstreamKey is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should create a new key set", async () => {
|
||||
const externalKey = createSymmetricKey();
|
||||
const userKey = createSymmetricKey();
|
||||
const encryptedUserKey = new EncString("encryptedUserKey");
|
||||
const encryptedPublicKey = new EncString("encryptedPublicKey");
|
||||
const encryptedPrivateKey = new EncString("encryptedPrivateKey");
|
||||
keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey]);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey);
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey);
|
||||
|
||||
const result = await service.createKeySet(externalKey, userKey);
|
||||
|
||||
expect(result).toEqual(
|
||||
new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey),
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(externalKey);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
userKey,
|
||||
Utils.fromB64ToArray("publicKey"),
|
||||
);
|
||||
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
Utils.fromB64ToArray("publicKey"),
|
||||
userKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateKeySet", () => {
|
||||
const keySet = new RotateableKeySet(
|
||||
new EncString("encUserKey"),
|
||||
new EncString("encPublicKey"),
|
||||
new EncString("encPrivateKey"),
|
||||
);
|
||||
const dataValidationTests = [
|
||||
{
|
||||
keySet: null as any as RotateableKeySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: keySet is required",
|
||||
},
|
||||
{
|
||||
keySet: undefined as any as RotateableKeySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: keySet is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: null,
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: oldDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: undefined,
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: oldDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: null,
|
||||
expectedError: "failed to rotate key set: newDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: undefined,
|
||||
expectedError: "failed to rotate key set: newDownstreamKey is required",
|
||||
},
|
||||
];
|
||||
|
||||
test.each(dataValidationTests)(
|
||||
"should throw error when required parameter is missing",
|
||||
async ({ keySet, oldDownstreamKey, newDownstreamKey, expectedError }) => {
|
||||
await expect(
|
||||
service.rotateKeySet(keySet, oldDownstreamKey as any, newDownstreamKey as any),
|
||||
).rejects.toThrow(expectedError);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error if the public key cannot be decrypted", async () => {
|
||||
const oldDownstreamKey = createSymmetricKey();
|
||||
const newDownstreamKey = createSymmetricKey();
|
||||
|
||||
encryptService.unwrapEncapsulationKey.mockResolvedValue(null as any);
|
||||
|
||||
await expect(
|
||||
service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey),
|
||||
).rejects.toThrow("failed to rotate key set: could not decrypt public key");
|
||||
|
||||
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
|
||||
expect(encryptService.wrapEncapsulationKey).not.toHaveBeenCalled();
|
||||
expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rotates the key set", async () => {
|
||||
const oldDownstreamKey = createSymmetricKey();
|
||||
const newDownstreamKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const publicKey = Utils.fromB64ToArray("decryptedPublicKey");
|
||||
const newEncryptedPublicKey = new EncString("newEncPublicKey");
|
||||
const newEncryptedRotateableKey = new EncString("newEncUserKey");
|
||||
|
||||
encryptService.unwrapEncapsulationKey.mockResolvedValue(publicKey);
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(newEncryptedPublicKey);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(newEncryptedRotateableKey);
|
||||
|
||||
const result = await service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey);
|
||||
|
||||
expect(result).toEqual(
|
||||
new RotateableKeySet(
|
||||
newEncryptedRotateableKey,
|
||||
newEncryptedPublicKey,
|
||||
keySet.encryptedPrivateKey,
|
||||
),
|
||||
);
|
||||
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(publicKey, newDownstreamKey);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
newDownstreamKey,
|
||||
publicKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createSymmetricKey() {
|
||||
const key = Utils.fromB64ToArray(
|
||||
"1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ",
|
||||
);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { RotateableKeySet } from "../models/rotateable-key-set";
|
||||
|
||||
import { RotateableKeySetService } from "./abstractions/rotateable-key-set.service";
|
||||
|
||||
export class DefaultRotateableKeySetService implements RotateableKeySetService {
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
) {}
|
||||
|
||||
async createKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
upstreamKey: UpstreamKey,
|
||||
downstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>> {
|
||||
if (!upstreamKey) {
|
||||
throw new Error("failed to create key set: upstreamKey is required");
|
||||
}
|
||||
if (!downstreamKey) {
|
||||
throw new Error("failed to create key set: downstreamKey is required");
|
||||
}
|
||||
|
||||
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(upstreamKey);
|
||||
|
||||
const rawPublicKey = Utils.fromB64ToArray(publicKey);
|
||||
const encryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
downstreamKey,
|
||||
rawPublicKey,
|
||||
);
|
||||
const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
rawPublicKey,
|
||||
downstreamKey,
|
||||
);
|
||||
return new RotateableKeySet(encryptedRotateableKey, encryptedPublicKey, encryptedPrivateKey);
|
||||
}
|
||||
|
||||
async rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
keySet: RotateableKeySet<UpstreamKey>,
|
||||
oldDownstreamKey: SymmetricCryptoKey,
|
||||
newDownstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>> {
|
||||
// validate parameters
|
||||
if (!keySet) {
|
||||
throw new Error("failed to rotate key set: keySet is required");
|
||||
}
|
||||
if (!oldDownstreamKey) {
|
||||
throw new Error("failed to rotate key set: oldDownstreamKey is required");
|
||||
}
|
||||
if (!newDownstreamKey) {
|
||||
throw new Error("failed to rotate key set: newDownstreamKey is required");
|
||||
}
|
||||
|
||||
const publicKey = await this.encryptService.unwrapEncapsulationKey(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
if (publicKey == null) {
|
||||
throw new Error("failed to rotate key set: could not decrypt public key");
|
||||
}
|
||||
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
publicKey,
|
||||
newDownstreamKey,
|
||||
);
|
||||
const newEncryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newDownstreamKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const newRotateableKeySet = new RotateableKeySet<UpstreamKey>(
|
||||
newEncryptedRotateableKey,
|
||||
newEncryptedPublicKey,
|
||||
keySet.encryptedPrivateKey,
|
||||
);
|
||||
|
||||
return newRotateableKeySet;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="tw-inline-flex tw-flex-wrap tw-gap-2">
|
||||
@for (item of filteredItems; track item; let last = $last) {
|
||||
@for (item of filteredItems(); track item; let last = $last) {
|
||||
<span bitBadge [variant]="variant()" [truncate]="truncate()">
|
||||
{{ item }}
|
||||
</span>
|
||||
@if (!last || isFiltered) {
|
||||
@if (!last || isFiltered()) {
|
||||
<span class="tw-sr-only">, </span>
|
||||
}
|
||||
}
|
||||
@if (isFiltered) {
|
||||
@if (isFiltered()) {
|
||||
<span bitBadge [variant]="variant()">
|
||||
{{ "plusNMore" | i18n: (items().length - filteredItems.length).toString() }}
|
||||
{{ "plusNMore" | i18n: (items().length - filteredItems().length).toString() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
import { Component, OnChanges, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BadgeModule, BadgeVariant } from "../badge";
|
||||
|
||||
function transformMaxItems(value: number | undefined) {
|
||||
return value == undefined ? undefined : Math.max(1, value);
|
||||
return value == null ? undefined : Math.max(1, value);
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* Displays a collection of badges in a horizontal, wrapping layout.
|
||||
*
|
||||
* The component automatically handles overflow by showing a limited number of badges
|
||||
* followed by a "+N more" badge when `maxItems` is specified and exceeded.
|
||||
*
|
||||
* Each badge inherits the `variant` and `truncate` settings, ensuring visual consistency
|
||||
* across the list. Badges are separated by commas for screen readers to improve accessibility.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-badge-list",
|
||||
templateUrl: "badge-list.component.html",
|
||||
imports: [BadgeModule, I18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BadgeListComponent implements OnChanges {
|
||||
protected filteredItems: string[] = [];
|
||||
protected isFiltered = false;
|
||||
|
||||
export class BadgeListComponent {
|
||||
/**
|
||||
* The visual variant to apply to all badges in the list.
|
||||
*/
|
||||
readonly variant = input<BadgeVariant>("primary");
|
||||
|
||||
/**
|
||||
* Items to display as badges.
|
||||
*/
|
||||
readonly items = input<string[]>([]);
|
||||
|
||||
/**
|
||||
* Whether to truncate long badge text with ellipsis.
|
||||
*/
|
||||
readonly truncate = input(true);
|
||||
|
||||
/**
|
||||
* Maximum number of badges to display before showing a "+N more" badge.
|
||||
*/
|
||||
readonly maxItems = input(undefined, { transform: transformMaxItems });
|
||||
|
||||
ngOnChanges() {
|
||||
protected readonly filteredItems = computed(() => {
|
||||
const maxItems = this.maxItems();
|
||||
const items = this.items();
|
||||
|
||||
if (maxItems == undefined || this.items().length <= maxItems) {
|
||||
this.filteredItems = this.items();
|
||||
} else {
|
||||
this.filteredItems = this.items().slice(0, maxItems - 1);
|
||||
if (maxItems == null || items.length <= maxItems) {
|
||||
return items;
|
||||
}
|
||||
this.isFiltered = this.items().length > this.filteredItems.length;
|
||||
}
|
||||
return items.slice(0, maxItems - 1);
|
||||
});
|
||||
|
||||
protected readonly isFiltered = computed(() => {
|
||||
return this.items().length > this.filteredItems().length;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, HostBinding, input } from "@angular/core";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
@@ -44,27 +51,56 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
|
||||
],
|
||||
};
|
||||
/**
|
||||
* Badges are primarily used as labels, counters, and small buttons.
|
||||
|
||||
* Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted.
|
||||
|
||||
* The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
|
||||
|
||||
* > `NOTE:` The Focus and Hover states only apply to badges used for interactive events.
|
||||
*
|
||||
* > `NOTE:` The `disabled` state only applies to buttons.
|
||||
*
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
* Badges are primarily used as labels, counters, and small buttons.
|
||||
* Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted.
|
||||
*
|
||||
* The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
|
||||
*
|
||||
* > `NOTE:` The Focus and Hover states only apply to badges used for interactive events.
|
||||
*
|
||||
* > `NOTE:` The `disabled` state only applies to buttons.
|
||||
*/
|
||||
@Component({
|
||||
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
|
||||
providers: [{ provide: FocusableElement, useExisting: BadgeComponent }],
|
||||
imports: [CommonModule],
|
||||
templateUrl: "badge.component.html",
|
||||
host: {
|
||||
"[class]": "classList()",
|
||||
"[attr.title]": "titleAttr()",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BadgeComponent implements FocusableElement {
|
||||
@HostBinding("class") get classList() {
|
||||
private readonly el = inject(ElementRef<HTMLElement>);
|
||||
|
||||
private readonly hasHoverEffects = this.el.nativeElement.nodeName !== "SPAN";
|
||||
|
||||
/**
|
||||
* Optional override for the automatic badge title attribute when truncating.
|
||||
* When truncating is enabled and this is not provided, the badge will automatically
|
||||
* use its text content as the title.
|
||||
*/
|
||||
readonly title = input<string>();
|
||||
|
||||
/**
|
||||
* Visual variant that determines the badge's color scheme.
|
||||
*/
|
||||
readonly variant = input<BadgeVariant>("primary");
|
||||
|
||||
/**
|
||||
* Whether to truncate long text with ellipsis when it exceeds maxWidthClass.
|
||||
* When enabled, a title attribute is automatically added for accessibility.
|
||||
*/
|
||||
readonly truncate = input(true);
|
||||
|
||||
/**
|
||||
* Tailwind max-width class to apply when truncating is enabled.
|
||||
* Must be a valid Tailwind max-width utility class (e.g., "tw-max-w-40", "tw-max-w-xs").
|
||||
*/
|
||||
readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
|
||||
|
||||
protected readonly classList = computed(() => {
|
||||
return [
|
||||
"tw-inline-block",
|
||||
"tw-py-1",
|
||||
@@ -94,39 +130,17 @@ export class BadgeComponent implements FocusableElement {
|
||||
.concat(styles[this.variant()])
|
||||
.concat(this.hasHoverEffects ? [...hoverStyles[this.variant()], "tw-min-w-10"] : [])
|
||||
.concat(this.truncate() ? this.maxWidthClass() : []);
|
||||
}
|
||||
@HostBinding("attr.title") get titleAttr() {
|
||||
});
|
||||
|
||||
protected readonly titleAttr = computed(() => {
|
||||
const title = this.title();
|
||||
if (title !== undefined) {
|
||||
return title;
|
||||
}
|
||||
return this.truncate() ? this?.el?.nativeElement?.textContent?.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional override for the automatic badge title when truncating.
|
||||
*/
|
||||
readonly title = input<string>();
|
||||
|
||||
/**
|
||||
* Variant, sets the background color of the badge.
|
||||
*/
|
||||
readonly variant = input<BadgeVariant>("primary");
|
||||
|
||||
/**
|
||||
* Truncate long text
|
||||
*/
|
||||
readonly truncate = input(true);
|
||||
|
||||
readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
|
||||
return this.truncate() ? this.el.nativeElement?.textContent?.trim() : null;
|
||||
});
|
||||
|
||||
getFocusTarget() {
|
||||
return this.el.nativeElement;
|
||||
}
|
||||
|
||||
private hasHoverEffects = false;
|
||||
|
||||
constructor(private el: ElementRef<HTMLElement>) {
|
||||
this.hasHoverEffects = el?.nativeElement?.nodeName != "SPAN";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ export { RemovePasswordComponent } from "./key-connector/remove-password.compone
|
||||
export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component";
|
||||
export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component";
|
||||
export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service";
|
||||
export { SessionTimeoutInputComponent } from "./session-timeout/components/session-timeout-input.component";
|
||||
|
||||
@@ -12,11 +12,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "./vault-timeout-input.component";
|
||||
import { SessionTimeoutInputComponent } from "./session-timeout-input.component";
|
||||
|
||||
describe("VaultTimeoutInputComponent", () => {
|
||||
let component: VaultTimeoutInputComponent;
|
||||
let fixture: ComponentFixture<VaultTimeoutInputComponent>;
|
||||
describe("SessionTimeoutInputComponent", () => {
|
||||
let component: SessionTimeoutInputComponent;
|
||||
let fixture: ComponentFixture<SessionTimeoutInputComponent>;
|
||||
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
|
||||
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
@@ -24,7 +24,7 @@ describe("VaultTimeoutInputComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultTimeoutInputComponent],
|
||||
imports: [SessionTimeoutInputComponent],
|
||||
providers: [
|
||||
{ provide: PolicyService, useValue: { policiesByType$ } },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
@@ -33,7 +33,7 @@ describe("VaultTimeoutInputComponent", () => {
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultTimeoutInputComponent);
|
||||
fixture = TestBed.createComponent(SessionTimeoutInputComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.vaultTimeoutOptions = [
|
||||
{ name: "oneMinute", value: 1 },
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { FormFieldModule, SelectModule } from "@bitwarden/components";
|
||||
|
||||
type VaultTimeoutForm = FormGroup<{
|
||||
@@ -47,34 +45,66 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "auth-vault-timeout-input",
|
||||
templateUrl: "vault-timeout-input.component.html",
|
||||
selector: "bit-session-timeout-input",
|
||||
templateUrl: "session-timeout-input.component.html",
|
||||
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
multi: true,
|
||||
useExisting: VaultTimeoutInputComponent,
|
||||
useExisting: SessionTimeoutInputComponent,
|
||||
},
|
||||
{
|
||||
provide: NG_VALIDATORS,
|
||||
multi: true,
|
||||
useExisting: VaultTimeoutInputComponent,
|
||||
useExisting: SessionTimeoutInputComponent,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class VaultTimeoutInputComponent
|
||||
export class SessionTimeoutInputComponent
|
||||
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
|
||||
{
|
||||
static CUSTOM_VALUE = -100;
|
||||
static MIN_CUSTOM_MINUTES = 0;
|
||||
form: VaultTimeoutForm = this.formBuilder.group({
|
||||
vaultTimeout: [null],
|
||||
custom: this.formBuilder.group({
|
||||
hours: [null],
|
||||
minutes: [null],
|
||||
}),
|
||||
});
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
|
||||
|
||||
vaultTimeoutPolicy: Policy;
|
||||
vaultTimeoutPolicyHours: number;
|
||||
vaultTimeoutPolicyMinutes: number;
|
||||
|
||||
protected readonly VaultTimeoutAction = VaultTimeoutAction;
|
||||
|
||||
protected canLockVault$: Observable<boolean>;
|
||||
private onChange: (vaultTimeout: VaultTimeout) => void;
|
||||
private validatorChange: () => void;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private policyService: PolicyService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private i18nService: I18nService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
get showCustom() {
|
||||
return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE;
|
||||
return this.form.get("vaultTimeout").value === SessionTimeoutInputComponent.CUSTOM_VALUE;
|
||||
}
|
||||
|
||||
get exceedsMinimumTimeout(): boolean {
|
||||
return (
|
||||
!this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES
|
||||
!this.showCustom ||
|
||||
this.customTimeInMinutes() > SessionTimeoutInputComponent.MIN_CUSTOM_MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,39 +131,6 @@ export class VaultTimeoutInputComponent
|
||||
});
|
||||
}
|
||||
|
||||
static CUSTOM_VALUE = -100;
|
||||
static MIN_CUSTOM_MINUTES = 0;
|
||||
|
||||
form: VaultTimeoutForm = this.formBuilder.group({
|
||||
vaultTimeout: [null],
|
||||
custom: this.formBuilder.group({
|
||||
hours: [null],
|
||||
minutes: [null],
|
||||
}),
|
||||
});
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
|
||||
|
||||
vaultTimeoutPolicy: Policy;
|
||||
vaultTimeoutPolicyHours: number;
|
||||
vaultTimeoutPolicyMinutes: number;
|
||||
|
||||
protected canLockVault$: Observable<boolean>;
|
||||
|
||||
private onChange: (vaultTimeout: VaultTimeout) => void;
|
||||
private validatorChange: () => void;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private policyService: PolicyService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private i18nService: I18nService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
@@ -163,7 +160,7 @@ export class VaultTimeoutInputComponent
|
||||
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
|
||||
this.form.controls.vaultTimeout.valueChanges
|
||||
.pipe(
|
||||
filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE),
|
||||
filter((value) => value !== SessionTimeoutInputComponent.CUSTOM_VALUE),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((value) => {
|
||||
@@ -195,17 +192,17 @@ export class VaultTimeoutInputComponent
|
||||
|
||||
ngOnChanges() {
|
||||
if (
|
||||
!this.vaultTimeoutOptions.find((p) => p.value === VaultTimeoutInputComponent.CUSTOM_VALUE)
|
||||
!this.vaultTimeoutOptions.find((p) => p.value === SessionTimeoutInputComponent.CUSTOM_VALUE)
|
||||
) {
|
||||
this.vaultTimeoutOptions.push({
|
||||
name: this.i18nService.t("custom"),
|
||||
value: VaultTimeoutInputComponent.CUSTOM_VALUE,
|
||||
value: SessionTimeoutInputComponent.CUSTOM_VALUE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getVaultTimeout(value: VaultTimeoutFormValue) {
|
||||
if (value.vaultTimeout !== VaultTimeoutInputComponent.CUSTOM_VALUE) {
|
||||
if (value.vaultTimeout !== SessionTimeoutInputComponent.CUSTOM_VALUE) {
|
||||
return value.vaultTimeout;
|
||||
}
|
||||
|
||||
@@ -219,7 +216,7 @@ export class VaultTimeoutInputComponent
|
||||
|
||||
if (this.vaultTimeoutOptions.every((p) => p.value !== value)) {
|
||||
this.form.setValue({
|
||||
vaultTimeout: VaultTimeoutInputComponent.CUSTOM_VALUE,
|
||||
vaultTimeout: SessionTimeoutInputComponent.CUSTOM_VALUE,
|
||||
custom: {
|
||||
hours: Math.floor(value / 60),
|
||||
minutes: value % 60,
|
||||
@@ -271,7 +268,7 @@ export class VaultTimeoutInputComponent
|
||||
|
||||
this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => {
|
||||
// Always include the custom option
|
||||
if (vaultTimeoutOption.value === VaultTimeoutInputComponent.CUSTOM_VALUE) {
|
||||
if (vaultTimeoutOption.value === SessionTimeoutInputComponent.CUSTOM_VALUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<auth-vault-timeout-input
|
||||
<bit-session-timeout-input
|
||||
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
|
||||
[formControl]="formGroup.controls.timeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
</bit-session-timeout-input>
|
||||
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -22,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutInputComponent } from "../components/session-timeout-input.component";
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
import { SessionTimeoutSettingsComponent } from "./session-timeout-settings.component";
|
||||
@@ -87,7 +87,7 @@ describe("SessionTimeoutSettingsComponent", () => {
|
||||
imports: [
|
||||
SessionTimeoutSettingsComponent,
|
||||
ReactiveFormsModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutInputComponent,
|
||||
NoopAnimationsModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
@@ -56,6 +55,8 @@ import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
import { SessionTimeoutInputComponent } from "./session-timeout-input.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -74,7 +75,7 @@ import { SessionTimeoutSettingsComponentService } from "../services/session-time
|
||||
RouterModule,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutInputComponent,
|
||||
],
|
||||
})
|
||||
export class SessionTimeoutSettingsComponent implements OnInit {
|
||||
|
||||
@@ -166,16 +166,16 @@ export abstract class KeyService {
|
||||
*/
|
||||
abstract makeMasterKey(password: string, email: string, kdfConfig: KdfConfig): Promise<MasterKey>;
|
||||
/**
|
||||
* Encrypts the existing (or provided) user key with the
|
||||
* provided master key
|
||||
* Encrypts the provided user key with the provided master key.
|
||||
* @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead.
|
||||
* @param masterKey The user's master key
|
||||
* @param userKey The user key
|
||||
* @throws Error when userKey or masterKey is null/undefined.
|
||||
* @returns The user key and the master key protected version of it
|
||||
*/
|
||||
abstract encryptUserKeyWithMasterKey(
|
||||
masterKey: MasterKey,
|
||||
userKey?: UserKey,
|
||||
userKey: UserKey,
|
||||
): Promise<[UserKey, EncString]>;
|
||||
/**
|
||||
* Creates a master password hash from the user's master password. Can
|
||||
|
||||
@@ -1357,6 +1357,51 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptUserKeyWithMasterKey", () => {
|
||||
const mockMasterKey = makeSymmetricCryptoKey<MasterKey>(32);
|
||||
const mockUserKey = makeSymmetricCryptoKey<UserKey>(64);
|
||||
|
||||
test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])(
|
||||
"throws when the provided master key is %s",
|
||||
async (key) => {
|
||||
await expect(keyService.encryptUserKeyWithMasterKey(key, mockUserKey)).rejects.toThrow(
|
||||
"masterKey is required.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserKey, undefined as unknown as UserKey])(
|
||||
"throws when the provided userKey key is %s",
|
||||
async (key) => {
|
||||
await expect(keyService.encryptUserKeyWithMasterKey(mockMasterKey, key)).rejects.toThrow(
|
||||
"userKey is required.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws with invalid master key size", async () => {
|
||||
const invalidMasterKey = new SymmetricCryptoKey(new Uint8Array(78)) as MasterKey;
|
||||
|
||||
await expect(
|
||||
keyService.encryptUserKeyWithMasterKey(invalidMasterKey, mockUserKey),
|
||||
).rejects.toThrow("Invalid key size.");
|
||||
});
|
||||
|
||||
it("encrypts the user key with the master key", async () => {
|
||||
const mockEncryptedUserKey = makeEncString("encryptedUserKey");
|
||||
|
||||
encryptService.wrapSymmetricKey.mockResolvedValue(mockEncryptedUserKey);
|
||||
const stretchedMasterKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
keyGenerationService.stretchKey.mockResolvedValue(stretchedMasterKey);
|
||||
|
||||
const result = await keyService.encryptUserKeyWithMasterKey(mockMasterKey, mockUserKey);
|
||||
|
||||
expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockUserKey, stretchedMasterKey);
|
||||
expect(result[0]).toBe(mockUserKey);
|
||||
expect(result[1]).toBe(mockEncryptedUserKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeKeyPair", () => {
|
||||
test.each([null as unknown as SymmetricCryptoKey, undefined as unknown as SymmetricCryptoKey])(
|
||||
"throws when the provided key is %s",
|
||||
|
||||
@@ -166,6 +166,9 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
return this.stateProvider.getUserState$(USER_KEY, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link userKey$} with a required {@link UserId} instead.
|
||||
*/
|
||||
async getUserKey(userId?: UserId): Promise<UserKey> {
|
||||
const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
return userKey;
|
||||
@@ -298,9 +301,15 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
*/
|
||||
async encryptUserKeyWithMasterKey(
|
||||
masterKey: MasterKey,
|
||||
userKey?: UserKey,
|
||||
userKey: UserKey,
|
||||
): Promise<[UserKey, EncString]> {
|
||||
userKey ||= await this.getUserKey();
|
||||
if (masterKey == null) {
|
||||
throw new Error("masterKey is required.");
|
||||
}
|
||||
if (userKey == null) {
|
||||
throw new Error("userKey is required.");
|
||||
}
|
||||
|
||||
return await this.buildProtectedSymmetricKey(masterKey, userKey);
|
||||
}
|
||||
|
||||
@@ -630,7 +639,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
// Verify user key doesn't exist
|
||||
const existingUserKey = await this.getUserKey(userId);
|
||||
const existingUserKey = await firstValueFrom(this.userKey$(userId));
|
||||
|
||||
if (existingUserKey != null) {
|
||||
this.logService.error("Tried to initialize account with existing user key.");
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<span
|
||||
*ngIf="hasDiscount()"
|
||||
bitBadge
|
||||
variant="success"
|
||||
class="tw-w-fit"
|
||||
role="status"
|
||||
[attr.aria-label]="getDiscountText()"
|
||||
>
|
||||
{{ getDiscountText() }}
|
||||
</span>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs";
|
||||
import * as DiscountBadgeStories from "./discount-badge.component.stories";
|
||||
|
||||
<Meta of={DiscountBadgeStories} />
|
||||
|
||||
# Discount Badge
|
||||
|
||||
A reusable UI component for displaying discount information (percentage or fixed amount) in a badge
|
||||
format.
|
||||
|
||||
<Canvas of={DiscountBadgeStories.PercentDiscount} />
|
||||
|
||||
## Usage
|
||||
|
||||
The discount badge component is designed to be used in billing and subscription interfaces to
|
||||
display discount information.
|
||||
|
||||
```ts
|
||||
import { DiscountBadgeComponent, DiscountInfo } from "@bitwarden/pricing";
|
||||
```
|
||||
|
||||
```html
|
||||
<billing-discount-badge [discount]="discountInfo"></billing-discount-badge>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ---------- | ---------------------- | -------------------------------------------------------------------------------- |
|
||||
| `discount` | `DiscountInfo \| null` | **Optional.** Discount information object. If null or inactive, badge is hidden. |
|
||||
|
||||
### DiscountInfo Interface
|
||||
|
||||
```ts
|
||||
interface DiscountInfo {
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** Percentage discount (0-100 or 0-1 scale) */
|
||||
percentOff?: number;
|
||||
/** Fixed amount discount in the base currency */
|
||||
amountOff?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- The badge is only displayed when `discount` is provided, `active` is `true`, and either
|
||||
`percentOff` or `amountOff` is greater than 0.
|
||||
- If both `percentOff` and `amountOff` are provided, `percentOff` takes precedence.
|
||||
- Percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%).
|
||||
- Amount values are formatted as currency (USD) with 2 decimal places.
|
||||
|
||||
## Examples
|
||||
|
||||
### Percentage Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.PercentDiscount} />
|
||||
|
||||
### Amount Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.AmountDiscount} />
|
||||
|
||||
### Inactive Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.InactiveDiscount} />
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DiscountBadgeComponent } from "./discount-badge.component";
|
||||
|
||||
describe("DiscountBadgeComponent", () => {
|
||||
let component: DiscountBadgeComponent;
|
||||
let fixture: ComponentFixture<DiscountBadgeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DiscountBadgeComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DiscountBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("hasDiscount", () => {
|
||||
it("should return false when discount is null", () => {
|
||||
fixture.componentRef.setInput("discount", null);
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when discount is inactive", () => {
|
||||
fixture.componentRef.setInput("discount", { active: false, percentOff: 20 });
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with percentOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with amountOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when percentOff is 0", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 0 });
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when amountOff is 0", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 0 });
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiscountText", () => {
|
||||
it("should return null when discount is null", () => {
|
||||
fixture.componentRef.setInput("discount", null);
|
||||
fixture.detectChanges();
|
||||
expect(component.getDiscountText()).toBeNull();
|
||||
});
|
||||
|
||||
it("should return percentage text when percentOff is provided", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
expect(text).toContain("20%");
|
||||
expect(text).toContain("discount");
|
||||
});
|
||||
|
||||
it("should convert decimal percentOff to percentage", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 0.15 });
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
expect(text).toContain("15%");
|
||||
});
|
||||
|
||||
it("should return amount text when amountOff is provided", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
expect(text).toContain("$10.99");
|
||||
expect(text).toContain("discount");
|
||||
});
|
||||
|
||||
it("should prefer percentOff over amountOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 25, amountOff: 10.99 });
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
expect(text).toContain("25%");
|
||||
expect(text).not.toContain("$10.99");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BadgeModule } from "@bitwarden/components";
|
||||
|
||||
import { DiscountBadgeComponent, DiscountInfo } from "./discount-badge.component";
|
||||
|
||||
export default {
|
||||
title: "Billing/Discount Badge",
|
||||
component: DiscountBadgeComponent,
|
||||
description: "A badge component that displays discount information (percentage or fixed amount).",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [BadgeModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "discount":
|
||||
return "discount";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta<DiscountBadgeComponent>;
|
||||
|
||||
type Story = StoryObj<DiscountBadgeComponent>;
|
||||
|
||||
export const PercentDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
percentOff: 20,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const PercentDiscountDecimal: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
percentOff: 0.15, // 15% in decimal format
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const AmountDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
amountOff: 10.99,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeAmountDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
amountOff: 99.99,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const InactiveDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: false,
|
||||
percentOff: 20,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const PercentAndAmountPreferPercent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
percentOff: 25,
|
||||
amountOff: 10.99,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BadgeModule } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* Interface for discount information that can be displayed in the discount badge.
|
||||
* This is abstracted from the response class to avoid tight coupling.
|
||||
*/
|
||||
export interface DiscountInfo {
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** Percentage discount (0-100 or 0-1 scale) */
|
||||
percentOff?: number;
|
||||
/** Fixed amount discount in the base currency */
|
||||
amountOff?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "billing-discount-badge",
|
||||
templateUrl: "./discount-badge.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, BadgeModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DiscountBadgeComponent {
|
||||
readonly discount = input<DiscountInfo | null>(null);
|
||||
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
getDiscountText(): string | null {
|
||||
const discount = this.discount();
|
||||
if (!discount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (discount.percentOff != null && discount.percentOff > 0) {
|
||||
const percentValue =
|
||||
discount.percentOff < 1 ? discount.percentOff * 100 : discount.percentOff;
|
||||
return `${Math.round(percentValue)}% ${this.i18nService.t("discount")}`;
|
||||
}
|
||||
|
||||
if (discount.amountOff != null && discount.amountOff > 0) {
|
||||
const formattedAmount = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(discount.amountOff);
|
||||
return `${formattedAmount} ${this.i18nService.t("discount")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
hasDiscount(): boolean {
|
||||
const discount = this.discount();
|
||||
if (!discount) {
|
||||
return false;
|
||||
}
|
||||
if (!discount.active) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(discount.percentOff != null && discount.percentOff > 0) ||
|
||||
(discount.amountOff != null && discount.amountOff > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Components
|
||||
export * from "./components/pricing-card/pricing-card.component";
|
||||
export * from "./components/cart-summary/cart-summary.component";
|
||||
export * from "./components/discount-badge/discount-badge.component";
|
||||
|
||||
Reference in New Issue
Block a user