1
0
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:
rr-bw
2025-11-13 11:41:11 -08:00
89 changed files with 2246 additions and 685 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -594,6 +594,9 @@
"viewAll": {
"message": "View all"
},
"viewLess": {
"message": "View less"
},
"viewLogin": {
"message": "View login"
},

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ export type FocusedFieldData = {
accountCreationFieldType?: string;
showPasskeys?: boolean;
focusedFieldForm?: string;
focusedFieldOpid?: string;
};
export type InlineMenuElementPosition = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
]);

View File

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

View File

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

View File

@@ -4,6 +4,5 @@
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
[disabled]="loading$ | async"
>
</bit-search>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -436,7 +436,7 @@ describe("UpgradePaymentService", () => {
tier: "families",
passwordManager: {
additionalStorage: 0,
seats: 6,
seats: 1,
sponsored: false,
},
},

View File

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

View File

@@ -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 ? "&times;" + i.quantity : "" }} &#64;
{{ 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>

View File

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

View File

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

View File

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

View File

@@ -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,
],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,2 @@
export * from "./rotateable-key-set";
export * from "./login-credentials";
export * from "./user-decryption-options";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<span
*ngIf="hasDiscount()"
bitBadge
variant="success"
class="tw-w-fit"
role="status"
[attr.aria-label]="getDiscountText()"
>
{{ getDiscountText() }}
</span>

View File

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

View File

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

View File

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

View File

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

View File

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