${NotificationHeader({
handleCloseNotification,
i18n,
diff --git a/apps/browser/src/autofill/content/components/notification/at-risk-password/footer.ts b/apps/browser/src/autofill/content/components/notification/at-risk-password/footer.ts
index d7805492fa6..e89edaf8b69 100644
--- a/apps/browser/src/autofill/content/components/notification/at-risk-password/footer.ts
+++ b/apps/browser/src/autofill/content/components/notification/at-risk-password/footer.ts
@@ -26,6 +26,7 @@ export function AtRiskNotificationFooter({
open(passwordChangeUri, "_blank");
},
buttonText: AdditionalTasksButtonContent({ buttonText: i18n.changePassword, theme }),
+ dataTestId: "change-password-button",
theme,
fullWidth: false,
})}
diff --git a/apps/browser/src/autofill/content/trigger-autofill-script-injection.ts b/apps/browser/src/autofill/content/trigger-autofill-script-injection.ts
index 95a2391991b..9db691c1359 100644
--- a/apps/browser/src/autofill/content/trigger-autofill-script-injection.ts
+++ b/apps/browser/src/autofill/content/trigger-autofill-script-injection.ts
@@ -1,5 +1,3 @@
(function () {
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- chrome.runtime.sendMessage({ command: "triggerAutofillScriptInjection" });
+ void chrome.runtime.sendMessage({ command: "triggerAutofillScriptInjection" });
})();
diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts
index 285ae4aa257..6e20a07f81f 100644
--- a/apps/browser/src/autofill/notification/bar.ts
+++ b/apps/browser/src/autofill/notification/bar.ts
@@ -200,7 +200,7 @@ export function getNotificationTestId(
[NotificationTypes.Unlock]: "unlock-notification-bar",
[NotificationTypes.Add]: "save-notification-bar",
[NotificationTypes.Change]: "update-notification-bar",
- [NotificationTypes.AtRiskPassword]: "at-risk-password-notification-bar",
+ [NotificationTypes.AtRiskPassword]: "at-risk-notification-bar",
}[notificationType];
}
@@ -287,6 +287,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
type: notificationBarIframeInitData.type as NotificationType,
theme: resolvedTheme,
i18n,
+ notificationTestId,
params: initData.params,
handleCloseNotification,
}),
diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts
index 55c3cced726..7467d5d4ba7 100644
--- a/apps/browser/src/autofill/services/autofill-constants.ts
+++ b/apps/browser/src/autofill/services/autofill-constants.ts
@@ -50,6 +50,15 @@ export class AutoFillConstants {
static readonly SearchFieldNames: string[] = ["search", "query", "find", "go"];
+ static readonly NewEmailFieldKeywords: string[] = [
+ "new-email",
+ "newemail",
+ "new email",
+ "neue e-mail",
+ ];
+
+ static readonly NewsletterFormNames: string[] = ["newsletter"];
+
static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"];
static readonly PasswordFieldExcludeList: string[] = [
diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts
index 6aa99bbda41..099f345cb75 100644
--- a/apps/browser/src/autofill/services/autofill.service.ts
+++ b/apps/browser/src/autofill/services/autofill.service.ts
@@ -213,9 +213,7 @@ export default class AutofillService implements AutofillServiceInterface {
this.autofillScriptPortsSet.delete(port);
});
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.injectAutofillScriptsInAllTabs();
+ void this.injectAutofillScriptsInAllTabs();
}
/**
@@ -470,9 +468,7 @@ export default class AutofillService implements AutofillServiceInterface {
await this.cipherService.updateLastUsedDate(options.cipher.id, activeAccount.id);
}
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- BrowserApi.tabSendMessage(
+ void BrowserApi.tabSendMessage(
tab,
{
command: options.autoSubmitLogin ? "triggerAutoSubmitLogin" : "fillForm",
@@ -502,9 +498,10 @@ export default class AutofillService implements AutofillServiceInterface {
);
if (didAutofill) {
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id);
+ await this.eventCollectionService.collect(
+ EventType.Cipher_ClientAutofilled,
+ options.cipher.id,
+ );
if (totp !== null) {
return totp;
} else {
diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
index 9b16a0cfbdd..b12017484eb 100644
--- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
+++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
@@ -58,6 +58,8 @@ export class InlineMenuFieldQualificationService
"neue e-mail",
"pwdcheck",
];
+ private newEmailFieldKeywords = new Set(AutoFillConstants.NewEmailFieldKeywords);
+ private newsletterFormKeywords = new Set(AutoFillConstants.NewsletterFormNames);
private updatePasswordFieldKeywords = [
"update password",
"change password",
@@ -152,6 +154,61 @@ export class InlineMenuFieldQualificationService
private totpFieldAutocompleteValue = "one-time-code";
private premiumEnabled = false;
+ /**
+ * Validates the provided field to indicate if the field is a new email field used for account creation/registration.
+ *
+ * @param field - The field to validate
+ */
+ private isExplicitIdentityEmailField(field: AutofillField): boolean {
+ const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder];
+ for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
+ if (!matchFieldAttributeValues[attrIndex]) {
+ continue;
+ }
+
+ for (let keywordIndex = 0; keywordIndex < matchFieldAttributeValues.length; keywordIndex++) {
+ if (this.newEmailFieldKeywords.has(matchFieldAttributeValues[attrIndex])) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Validates the provided form to indicate if the form is related to newsletter registration.
+ *
+ * @param parentForm - The form to validate
+ */
+ private isNewsletterForm(parentForm: any): boolean {
+ if (!parentForm) {
+ return false;
+ }
+
+ const matchFieldAttributeValues = [
+ parentForm.type,
+ parentForm.htmlName,
+ parentForm.htmlID,
+ parentForm.placeholder,
+ ];
+
+ for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
+ const attrValue = matchFieldAttributeValues[attrIndex];
+ if (!attrValue || typeof attrValue !== "string") {
+ continue;
+ }
+ const attrValueLower = attrValue.toLowerCase();
+ for (const keyword of this.newsletterFormKeywords) {
+ if (attrValueLower.includes(keyword.toLowerCase())) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
constructor() {
void Promise.all([
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
@@ -300,7 +357,11 @@ export class InlineMenuFieldQualificationService
return false;
}
- return this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues);
+ return (
+ // Recognize explicit identity email fields (like id="new-email")
+ this.isFieldForIdentityEmail(field) ||
+ this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues)
+ );
}
/**
@@ -397,6 +458,12 @@ export class InlineMenuFieldQualificationService
): boolean {
// If the provided field is set with an autocomplete of "username", we should assume that
// the page developer intends for this field to be interpreted as a username field.
+
+ // Exclude non-login email field from being treated as a login username field
+ if (this.isExplicitIdentityEmailField(field)) {
+ return false;
+ }
+
if (this.fieldContainsAutocompleteValues(field, this.loginUsernameAutocompleteValues)) {
const newPasswordFieldsInPageDetails = pageDetails.fields.filter(
(field) => field.viewable && this.isNewPasswordField(field),
@@ -415,6 +482,10 @@ export class InlineMenuFieldQualificationService
const parentForm = pageDetails.forms[field.form];
const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField);
+ if (this.isNewsletterForm(parentForm)) {
+ return false;
+ }
+
// If the field is not structured within a form, we need to identify if the field is used in conjunction
// with a password field. If that's the case, then we should assume that it is a form field element.
if (!parentForm) {
@@ -822,9 +893,14 @@ export class InlineMenuFieldQualificationService
* @param field - The field to validate
*/
isFieldForIdentityEmail = (field: AutofillField): boolean => {
+ if (this.isExplicitIdentityEmailField(field)) {
+ return true;
+ }
+
if (
this.fieldContainsAutocompleteValues(field, this.emailAutocompleteValue) ||
- field.type === "email"
+ field.type === "email" ||
+ field.htmlName === "email"
) {
return true;
}
diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts
index 6fd555f4287..aaa23a140db 100644
--- a/apps/browser/src/background/idle.background.ts
+++ b/apps/browser/src/background/idle.background.ts
@@ -1,5 +1,3 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -15,7 +13,7 @@ const IdleInterval = 60 * 5; // 5 minutes
export default class IdleBackground {
private idle: typeof chrome.idle | typeof browser.idle | null;
- private idleTimer: number | NodeJS.Timeout = null;
+ private idleTimer: null | number | NodeJS.Timeout = null;
private idleState = "active";
constructor(
@@ -80,9 +78,8 @@ export default class IdleBackground {
globalThis.clearTimeout(this.idleTimer);
this.idleTimer = null;
}
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.idle.queryState(IdleInterval, (state: string) => {
+
+ void this.idle?.queryState(IdleInterval, (state: string) => {
if (state !== this.idleState) {
this.idleState = state;
handler(state);
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index f88852069ea..4d3e3b25165 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -904,6 +904,7 @@ export default class MainBackground {
this.accountService,
this.logService,
this.cipherEncryptionService,
+ this.messagingService,
);
this.folderService = new FolderService(
this.keyService,
diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.html b/apps/browser/src/platform/popup/layout/popup-header.component.html
index c58bc258bf6..014ebc86411 100644
--- a/apps/browser/src/platform/popup/layout/popup-header.component.html
+++ b/apps/browser/src/platform/popup/layout/popup-header.component.html
@@ -1,16 +1,21 @@
+
-
{{ policy.description | i18n }}
+ @if (policy.showDescription) {
+
{{ policy.description | i18n }}
+ }
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts
index d3d03d2aaae..2984db67d39 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts
@@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit {
}
submit = async () => {
+ if ((await this.policyComponent.confirm()) == false) {
+ this.dialogRef.close();
+ return;
+ }
+
let request: PolicyRequest;
+
try {
request = await this.policyComponent.buildRequest();
} catch (e) {
this.toastService.showToast({ variant: "error", title: null, message: e.message });
return;
}
+
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
this.toastService.showToast({
variant: "success",
diff --git a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts
index 21de143dea6..3a0d196c593 100644
--- a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts
@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
+import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy {
type = PolicyType.RequireSso;
component = RequireSsoPolicyComponent;
- display(organization: Organization) {
- return organization.useSso;
+ display(organization: Organization, configService: ConfigService) {
+ return of(organization.useSso);
}
}
diff --git a/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts
index 62fc42f6a06..93a42285fbc 100644
--- a/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts
@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
-import { firstValueFrom } from "rxjs";
+import { firstValueFrom, of } from "rxjs";
import {
getOrganizationById,
@@ -10,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy {
type = PolicyType.ResetPassword;
component = ResetPasswordPolicyComponent;
- display(organization: Organization) {
- return organization.useResetPassword;
+ display(organization: Organization, configService: ConfigService) {
+ return of(organization.useResetPassword);
}
}
@@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements
throw new Error("No user found.");
}
+ if (!this.policyResponse) {
+ throw new Error("Policies not found");
+ }
+
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
diff --git a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts
index 1bee5583718..6cad0fc0170 100644
--- a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts
@@ -1,6 +1,10 @@
import { Component } from "@angular/core";
+import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -9,6 +13,10 @@ export class RestrictedItemTypesPolicy extends BasePolicy {
description = "restrictedItemTypePolicyDesc";
type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent;
+
+ display(organization: Organization, configService: ConfigService): Observable
{
+ return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy);
+ }
}
@Component({
diff --git a/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts b/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts
index ad32b4218bc..613253ef8d9 100644
--- a/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts
@@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI
async ngOnInit() {
super.ngOnInit();
+ if (!this.policyResponse) {
+ throw new Error("Policies not found");
+ }
if (!this.policyResponse.canToggleState) {
this.enabled.disable();
}
diff --git a/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html
new file mode 100644
index 00000000000..0abc40da683
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html
@@ -0,0 +1,57 @@
+
+ {{ "organizationDataOwnershipContent" | i18n }}
+
+ {{ "organizationDataOwnershipContentAnchor" | i18n }}.
+
+
+
+
+
+ {{ "turnOn" | i18n }}
+
+
+
+
+ {{ "organizationDataOwnershipWarningTitle" | i18n }}
+
+
+ {{ "organizationDataOwnershipWarningContentTop" | i18n }}
+
+
+ -
+ {{ "organizationDataOwnershipWarning1" | i18n }}
+
+ -
+ {{ "organizationDataOwnershipWarning2" | i18n }}
+
+ -
+ {{ "organizationDataOwnershipWarning3" | i18n }}
+
+
+
+ {{ "organizationDataOwnershipWarningContentBottom" | i18n }}
+
+ {{ "organizationDataOwnershipContentAnchor" | i18n }}.
+
+
+
+
+
+
+ {{ "continue" | i18n }}
+
+
+ {{ "cancel" | i18n }}
+
+
+
+
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts
new file mode 100644
index 00000000000..11b1548d9f9
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts
@@ -0,0 +1,50 @@
+import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
+import { lastValueFrom, Observable } from "rxjs";
+
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { DialogService } from "@bitwarden/components";
+
+import { SharedModule } from "../../../shared";
+
+import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
+
+export class vNextOrganizationDataOwnershipPolicy extends BasePolicy {
+ name = "organizationDataOwnership";
+ description = "organizationDataOwnershipDesc";
+ type = PolicyType.OrganizationDataOwnership;
+ component = vNextOrganizationDataOwnershipPolicyComponent;
+ showDescription = false;
+
+ override display(organization: Organization, configService: ConfigService): Observable {
+ return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
+ }
+}
+
+@Component({
+ selector: "vnext-policy-organization-data-ownership",
+ templateUrl: "vnext-organization-data-ownership.component.html",
+ standalone: true,
+ imports: [SharedModule],
+})
+export class vNextOrganizationDataOwnershipPolicyComponent
+ extends BasePolicyComponent
+ implements OnInit
+{
+ constructor(private dialogService: DialogService) {
+ super();
+ }
+
+ @ViewChild("dialog", { static: true }) warningContent!: TemplateRef;
+
+ override async confirm(): Promise {
+ if (this.policyResponse?.enabled && !this.enabled.value) {
+ const dialogRef = this.dialogService.open(this.warningContent);
+ const result = await lastValueFrom(dialogRef.closed);
+ return Boolean(result);
+ }
+ return true;
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts
index fabfb65fc6b..a0964a90fca 100644
--- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts
+++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts
@@ -39,6 +39,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
@@ -87,8 +88,8 @@ enum ButtonType {
}
export interface CollectionDialogParams {
- collectionId?: string;
- organizationId: string;
+ collectionId?: CollectionId;
+ organizationId: OrganizationId;
initialTab?: CollectionDialogTabType;
parentCollectionId?: string;
showOrgSelector?: boolean;
@@ -136,7 +137,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
externalId: { value: "", disabled: true },
parent: undefined as string | undefined,
access: [[] as AccessItemValue[]],
- selectedOrg: "",
+ selectedOrg: "" as OrganizationId,
});
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts
index 203bad3754a..0729d42f053 100644
--- a/apps/web/src/app/app.component.ts
+++ b/apps/web/src/app/app.component.ts
@@ -35,12 +35,14 @@ import {
MasterPasswordPolicy,
PasswordGeneratorPolicy,
OrganizationDataOwnershipPolicy,
+ vNextOrganizationDataOwnershipPolicy,
RequireSsoPolicy,
ResetPasswordPolicy,
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
RemoveUnlockWithPinPolicy,
+ RestrictedItemTypesPolicy,
} from "./admin-console/organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
@@ -244,8 +246,10 @@ export class AppComponent implements OnDestroy, OnInit {
new SingleOrgPolicy(),
new RequireSsoPolicy(),
new OrganizationDataOwnershipPolicy(),
+ new vNextOrganizationDataOwnershipPolicy(),
new DisableSendPolicy(),
new SendOptionsPolicy(),
+ new RestrictedItemTypesPolicy(),
]);
}
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
index ff062b31e6b..2ff38f6eab0 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { MockProxy } from "jest-mock-extended";
import mock from "jest-mock-extended/lib/Mock";
+import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -14,9 +15,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
-import { UserKey, MasterKey } from "@bitwarden/common/types/key";
+import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
-import { KdfType, KeyService } from "@bitwarden/key-management";
+import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -26,6 +27,7 @@ import {
EmergencyAccessGranteeDetailsResponse,
EmergencyAccessGrantorDetailsResponse,
EmergencyAccessTakeoverResponse,
+ EmergencyAccessViewResponse,
} from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@@ -142,88 +144,306 @@ describe("EmergencyAccessService", () => {
});
});
+ describe("getViewOnlyCiphers", () => {
+ const params = {
+ id: "emergency-access-id",
+ activeUserId: Utils.newGuid() as UserId,
+ };
+
+ it("throws an error is the active user's private key isn't available", async () => {
+ keyService.userPrivateKey$.mockReturnValue(of(null));
+
+ await expect(
+ emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId),
+ ).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers.");
+ });
+
+ it("should return decrypted and sorted ciphers", async () => {
+ const emergencyAccessViewResponse = {
+ keyEncrypted: "mockKeyEncrypted",
+ ciphers: [
+ { id: "cipher1", name: "encryptedName1" },
+ { id: "cipher2", name: "encryptedName2" },
+ ],
+ } as EmergencyAccessViewResponse;
+
+ const mockEncryptedCipher1 = {
+ id: "cipher1",
+ decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }),
+ };
+ const mockEncryptedCipher2 = {
+ id: "cipher2",
+ decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }),
+ };
+ emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => {
+ return [mockEncryptedCipher1, mockEncryptedCipher2];
+ });
+ cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) =>
+ a.id.localeCompare(b.id),
+ );
+ emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue(
+ emergencyAccessViewResponse,
+ );
+
+ const mockPrivateKey = new Uint8Array(64) as UserPrivateKey;
+ keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey));
+
+ const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
+ encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
+ const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
+
+ const result = await emergencyAccessService.getViewOnlyCiphers(
+ params.id,
+ params.activeUserId,
+ );
+
+ expect(result).toEqual([
+ { id: "cipher1", decrypted: true },
+ { id: "cipher2", decrypted: true },
+ ]);
+ expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
+ expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
+ expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id);
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(emergencyAccessViewResponse.keyEncrypted),
+ mockPrivateKey,
+ );
+ expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled();
+ });
+ });
+
describe("takeover", () => {
- const mockId = "emergencyAccessId";
- const mockEmail = "emergencyAccessEmail";
- const mockName = "emergencyAccessName";
+ const params = {
+ id: "emergencyAccessId",
+ masterPassword: "mockPassword",
+ email: "emergencyAccessEmail",
+ activeUserId: Utils.newGuid() as UserId,
+ };
+
+ const takeoverResponse = {
+ keyEncrypted: "EncryptedKey",
+ kdf: KdfType.PBKDF2_SHA256,
+ kdfIterations: 500,
+ } as EmergencyAccessTakeoverResponse;
+
+ const userPrivateKey = new Uint8Array(64) as UserPrivateKey;
+ const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
+ const mockMasterKeyHash = "mockMasterKeyHash";
+ let mockGrantorUserKey: UserKey;
+
+ // must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
+ // where UserKey is the decrypted grantor user key
+ const mockMasterKeyEncryptedUserKey = new EncString(
+ EncryptionType.AesCbc256_HmacSha256_B64,
+ "mockMasterKeyEncryptedUserKey",
+ );
+
+ beforeEach(() => {
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
+ keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
+
+ const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
+ encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
+ mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
+
+ keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
+ keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
+ keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
+ mockGrantorUserKey,
+ mockMasterKeyEncryptedUserKey,
+ ]);
+ });
it("posts a new password when decryption succeeds", async () => {
// Arrange
- emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
- keyEncrypted: "EncryptedKey",
- kdf: KdfType.PBKDF2_SHA256,
- kdfIterations: 500,
- } as EmergencyAccessTakeoverResponse);
-
- const mockDecryptedGrantorUserKey = new Uint8Array(64);
- keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
- encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
- new SymmetricCryptoKey(mockDecryptedGrantorUserKey),
- );
-
- const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
-
- keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
-
- const mockMasterKeyHash = "mockMasterKeyHash";
- keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
-
- // must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
- // where UserKey is the decrypted grantor user key
- const mockMasterKeyEncryptedUserKey = new EncString(
- EncryptionType.AesCbc256_HmacSha256_B64,
- "mockMasterKeyEncryptedUserKey",
- );
-
- const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey;
-
- keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
- mockUserKey,
- mockMasterKeyEncryptedUserKey,
- ]);
+ const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
// Act
- await emergencyAccessService.takeover(mockId, mockEmail, mockName);
+ await emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ );
// Assert
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(takeoverResponse.keyEncrypted),
+ userPrivateKey,
+ );
+ expect(keyService.makeMasterKey).toHaveBeenCalledWith(
+ params.masterPassword,
+ params.email,
+ expectedKdfConfig,
+ );
+ expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
+ expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
+ mockMasterKey,
+ mockGrantorUserKey,
+ );
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
- mockId,
+ params.id,
expectedEmergencyAccessPasswordRequest,
);
});
- it("should not post a new password if decryption fails", async () => {
- encryptService.rsaDecrypt.mockResolvedValueOnce(null);
- emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
+ it("uses argon2 KDF if takeover response is argon2", async () => {
+ const argon2TakeoverResponse = {
keyEncrypted: "EncryptedKey",
- kdf: KdfType.PBKDF2_SHA256,
- kdfIterations: 500,
- } as EmergencyAccessTakeoverResponse);
- keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
+ kdf: KdfType.Argon2id,
+ kdfIterations: 3,
+ kdfMemory: 64,
+ kdfParallelism: 4,
+ } as EmergencyAccessTakeoverResponse;
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockReset();
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(
+ argon2TakeoverResponse,
+ );
+
+ const expectedKdfConfig = new Argon2KdfConfig(
+ argon2TakeoverResponse.kdfIterations,
+ argon2TakeoverResponse.kdfMemory,
+ argon2TakeoverResponse.kdfParallelism,
+ );
+
+ const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
+ expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
+ expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
+
+ await emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ );
+
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(argon2TakeoverResponse.keyEncrypted),
+ userPrivateKey,
+ );
+ expect(keyService.makeMasterKey).toHaveBeenCalledWith(
+ params.masterPassword,
+ params.email,
+ expectedKdfConfig,
+ );
+ expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
+ expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
+ mockMasterKey,
+ mockGrantorUserKey,
+ );
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
+ params.id,
+ expectedEmergencyAccessPasswordRequest,
+ );
+ });
+
+ it("throws an error if masterKeyEncryptedUserKey is not found", async () => {
+ keyService.encryptUserKeyWithMasterKey.mockReset();
+ keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null);
+ const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
await expect(
- emergencyAccessService.takeover(mockId, mockEmail, mockName),
- ).rejects.toThrowError("Failed to decrypt grantor key");
+ emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ ),
+ ).rejects.toThrow("masterKeyEncryptedUserKey not found");
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(takeoverResponse.keyEncrypted),
+ userPrivateKey,
+ );
+ expect(keyService.makeMasterKey).toHaveBeenCalledWith(
+ params.masterPassword,
+ params.email,
+ expectedKdfConfig,
+ );
+ expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
+ expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
+ mockMasterKey,
+ mockGrantorUserKey,
+ );
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
+ });
+
+ it("should not post a new password if decryption fails", async () => {
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
+ encryptService.decapsulateKeyUnsigned.mockReset();
+ encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null);
+
+ await expect(
+ emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ ),
+ ).rejects.toThrow("Failed to decrypt grantor key");
+
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(takeoverResponse.keyEncrypted),
+ userPrivateKey,
+ );
+ expect(keyService.makeMasterKey).not.toHaveBeenCalled();
+ expect(keyService.hashMasterKey).not.toHaveBeenCalled();
+ expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
+ });
+
+ it("should not post a new password if decryption throws", async () => {
+ encryptService.decapsulateKeyUnsigned.mockReset();
+ encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => {
+ throw new Error("Failed to unwrap grantor key");
+ });
+
+ await expect(
+ emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ ),
+ ).rejects.toThrowError("Failed to unwrap grantor key");
+
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
+ new EncString(takeoverResponse.keyEncrypted),
+ userPrivateKey,
+ );
+ expect(keyService.makeMasterKey).not.toHaveBeenCalled();
+ expect(keyService.hashMasterKey).not.toHaveBeenCalled();
+ expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should throw an error if the users private key cannot be retrieved", async () => {
- emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
- keyEncrypted: "EncryptedKey",
- kdf: KdfType.PBKDF2_SHA256,
- kdfIterations: 500,
- } as EmergencyAccessTakeoverResponse);
- keyService.getPrivateKey.mockResolvedValue(null);
+ keyService.userPrivateKey$.mockReturnValue(of(null));
- await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow(
- "user does not have a private key",
- );
+ await expect(
+ emergencyAccessService.takeover(
+ params.id,
+ params.masterPassword,
+ params.email,
+ params.activeUserId,
+ ),
+ ).rejects.toThrow("user does not have a private key");
+ expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
+ expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
+ expect(keyService.makeMasterKey).not.toHaveBeenCalled();
+ expect(keyService.hashMasterKey).not.toHaveBeenCalled();
+ expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
});
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
index 9a31bd9c107..cce8d9345b2 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
@@ -1,4 +1,5 @@
import { Injectable } from "@angular/core";
+import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
@@ -237,11 +238,14 @@ export class EmergencyAccessService
* Gets the grantor ciphers for an emergency access in view mode.
* Intended for grantee.
* @param id emergency access id
+ * @param activeUserId the user id of the active user
*/
- async getViewOnlyCiphers(id: string): Promise {
+ async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise {
const response = await this.emergencyAccessApiService.postEmergencyAccessView(id);
- const activeUserPrivateKey = await this.keyService.getPrivateKey();
+ const activeUserPrivateKey = await firstValueFrom(
+ this.keyService.userPrivateKey$(activeUserId),
+ );
if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot get view only ciphers.");
@@ -264,11 +268,14 @@ export class EmergencyAccessService
* @param id emergency access id
* @param masterPassword new master password
* @param email email address of grantee (must be consistent or login will fail)
+ * @param activeUserId the user id of the active user
*/
- async takeover(id: string, masterPassword: string, email: string) {
+ async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id);
- const activeUserPrivateKey = await this.keyService.getPrivateKey();
+ const activeUserPrivateKey = await firstValueFrom(
+ this.keyService.userPrivateKey$(activeUserId),
+ );
if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot complete a takeover.");
@@ -312,9 +319,7 @@ export class EmergencyAccessService
request.newMasterPasswordHash = masterKeyHash;
request.key = encKey[1].encryptedString;
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
+ await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
}
private async getEmergencyAccessData(): Promise {
diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts
index 2619e6852b3..e5c21fb82b9 100644
--- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts
+++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts
@@ -115,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit {
this.parentSubmittingBehaviorSubject.next(true);
try {
+ const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.emergencyAccessService.takeover(
this.dialogData.emergencyAccessId,
passwordInputResult.newPassword,
this.dialogData.grantorEmail,
+ activeUserId,
);
} catch (e) {
this.logService.error(e);
diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts
index ce46e624972..250261fb0e7 100644
--- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts
+++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts
@@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
@@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit {
private route: ActivatedRoute,
private emergencyAccessService: EmergencyAccessService,
private dialogService: DialogService,
+ private accountService: AccountService,
) {}
async ngOnInit() {
@@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit {
}
this.id = qParams.id;
- this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id);
+ const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
+ this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId);
this.loaded = true;
}
diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts
index 556ba381acc..816da6e873f 100644
--- a/apps/web/src/app/auth/settings/security/device-management-old.component.ts
+++ b/apps/web/src/app/auth/settings/security/device-management-old.component.ts
@@ -3,7 +3,7 @@ import { Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
-import { LoginApprovalComponent } from "@bitwarden/auth/angular";
+import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
@@ -325,7 +325,7 @@ export class DeviceManagementOldComponent {
return;
}
- const dialogRef = LoginApprovalComponent.open(this.dialogService, {
+ const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: device.devicePendingAuthRequest.id,
});
diff --git a/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts b/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts
index 5edd8bc046e..7292e13a6a5 100644
--- a/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts
+++ b/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts
@@ -1,8 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
-import { Guid } from "@bitwarden/common/types/guid";
+import { OrganizationId } from "@bitwarden/common/types/guid";
export class RequestSMAccessRequest {
- OrganizationId: Guid;
+ OrganizationId: OrganizationId;
EmailContent: string;
}
diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
index 443b3e03e5f..0e32321a0b3 100644
--- a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
+++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
@@ -10,7 +10,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import { Guid } from "@bitwarden/common/types/guid";
import { NoItemsModule, SearchModule, ToastService } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
@@ -63,7 +62,7 @@ export class RequestSMAccessComponent implements OnInit {
const formValue = this.requestAccessForm.value;
const request = new RequestSMAccessRequest();
- request.OrganizationId = formValue.selectedOrganization.id as Guid;
+ request.OrganizationId = formValue.selectedOrganization.id;
request.EmailContent = formValue.requestAccessEmailContents;
await this.smLandingApiService.requestSMAccessFromAdmins(request);
diff --git a/apps/web/src/app/settings/domain-rules.component.html b/apps/web/src/app/settings/domain-rules.component.html
index 7474b63372d..8ebeecb429f 100644
--- a/apps/web/src/app/settings/domain-rules.component.html
+++ b/apps/web/src/app/settings/domain-rules.component.html
@@ -60,7 +60,6 @@
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html
index 1c643fcc3e4..56332cc424b 100644
--- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html
+++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html
@@ -26,14 +26,7 @@
-
- {{ "openExtensionManuallyPart1" | i18n }}
-
- {{ "openExtensionManuallyPart2" | i18n }}
-
+
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts
index 624275a8297..177311cbfde 100644
--- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts
+++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts
@@ -3,17 +3,17 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { ButtonComponent, IconModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
-import { VaultIcons } from "@bitwarden/vault";
import {
BrowserExtensionPromptService,
BrowserPromptState,
} from "../../services/browser-extension-prompt.service";
+import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
@Component({
selector: "vault-browser-extension-prompt",
templateUrl: "./browser-extension-prompt.component.html",
- imports: [CommonModule, I18nPipe, ButtonComponent, IconModule],
+ imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
})
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
/** Current state of the prompt page */
@@ -22,8 +22,6 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
/** All available page states */
protected BrowserPromptState = BrowserPromptState;
- protected BitwardenIcon = VaultIcons.BitwardenIcon;
-
/** Content of the meta[name="viewport"] element */
private viewportContent: string | null = null;
diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html
new file mode 100644
index 00000000000..22c36e51177
--- /dev/null
+++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html
@@ -0,0 +1,8 @@
+
+ {{ "openExtensionManuallyPart1" | i18n }}
+
+ {{ "openExtensionManuallyPart2" | i18n }}
+
diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts
new file mode 100644
index 00000000000..22041b61198
--- /dev/null
+++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts
@@ -0,0 +1,14 @@
+import { Component } from "@angular/core";
+
+import { IconModule } from "@bitwarden/components";
+import { I18nPipe } from "@bitwarden/ui-common";
+import { VaultIcons } from "@bitwarden/vault";
+
+@Component({
+ selector: "vault-manually-open-extension",
+ templateUrl: "./manually-open-extension.component.html",
+ imports: [I18nPipe, IconModule],
+})
+export class ManuallyOpenExtensionComponent {
+ protected BitwardenIcon = VaultIcons.BitwardenIcon;
+}
diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts
index d053e05c36b..6bde812065b 100644
--- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts
+++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts
@@ -34,8 +34,8 @@ export class AddExtensionVideosComponent {
/** CSS classes for the video container, pulled into the class only for readability. */
protected videoContainerClass = [
"tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]",
- `[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
- `[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
+ `[--overlay-opacity:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
+ `[--border-opacity:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
"after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear",
"before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear",
].join(" ");
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
index c23fa0aac35..ac24383a4d3 100644
--- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
+++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
@@ -54,3 +54,7 @@
+
+
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts
index e824cd92f37..8bb80e6fb44 100644
--- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts
+++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts
@@ -1,4 +1,4 @@
-import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject } from "rxjs";
@@ -11,10 +11,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
+import { AnonLayoutWrapperDataService } from "@bitwarden/components";
+import { VaultIcons } from "@bitwarden/vault";
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
-import { SetupExtensionComponent } from "./setup-extension.component";
+import { SetupExtensionComponent, SetupExtensionState } from "./setup-extension.component";
describe("SetupExtensionComponent", () => {
let fixture: ComponentFixture;
@@ -24,12 +26,14 @@ describe("SetupExtensionComponent", () => {
const navigate = jest.fn().mockResolvedValue(true);
const openExtension = jest.fn().mockResolvedValue(true);
const update = jest.fn().mockResolvedValue(true);
+ const setAnonLayoutWrapperData = jest.fn();
const extensionInstalled$ = new BehaviorSubject(null);
beforeEach(async () => {
navigate.mockClear();
openExtension.mockClear();
update.mockClear();
+ setAnonLayoutWrapperData.mockClear();
getFeatureFlag.mockClear().mockResolvedValue(true);
window.matchMedia = jest.fn().mockReturnValue(false);
@@ -40,6 +44,7 @@ describe("SetupExtensionComponent", () => {
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
+ { provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } },
{
provide: AccountService,
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
@@ -136,6 +141,27 @@ describe("SetupExtensionComponent", () => {
it("dismisses the extension page", () => {
expect(update).toHaveBeenCalledTimes(1);
});
+
+ it("shows error state when extension fails to open", fakeAsync(() => {
+ openExtension.mockRejectedValueOnce(new Error("Failed to open extension"));
+
+ const openExtensionButton = fixture.debugElement.query(By.css("button"));
+
+ openExtensionButton.triggerEventHandler("click");
+
+ tick();
+
+ expect(component["state"]).toBe(SetupExtensionState.ManualOpen);
+ expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({
+ pageTitle: {
+ key: "somethingWentWrong",
+ },
+ pageIcon: VaultIcons.BrowserExtensionIcon,
+ hideIcon: false,
+ hideCardWrapper: false,
+ maxWidth: "md",
+ });
+ }));
});
});
});
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
index 14770ca5d6c..67d13ef1e4f 100644
--- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
+++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
@@ -15,6 +15,7 @@ import { StateProvider } from "@bitwarden/common/platform/state";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
import {
+ AnonLayoutWrapperDataService,
ButtonComponent,
DialogRef,
DialogService,
@@ -25,6 +26,7 @@ import { VaultIcons } from "@bitwarden/vault";
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
+import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
import {
AddExtensionLaterDialogComponent,
@@ -32,10 +34,11 @@ import {
} from "./add-extension-later-dialog.component";
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
-const SetupExtensionState = {
+export const SetupExtensionState = {
Loading: "loading",
NeedsExtension: "needs-extension",
Success: "success",
+ ManualOpen: "manual-open",
} as const;
type SetupExtensionState = UnionOfValues;
@@ -51,6 +54,7 @@ type SetupExtensionState = UnionOfValues;
IconModule,
RouterModule,
AddExtensionVideosComponent,
+ ManuallyOpenExtensionComponent,
],
})
export class SetupExtensionComponent implements OnInit, OnDestroy {
@@ -63,6 +67,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
private stateProvider = inject(StateProvider);
private accountService = inject(AccountService);
private document = inject(DOCUMENT);
+ private anonLayoutWrapperDataService = inject(AnonLayoutWrapperDataService);
protected SetupExtensionState = SetupExtensionState;
protected PartyIcon = VaultIcons.Party;
@@ -153,8 +158,21 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
}
/** Opens the browser extension */
- openExtension() {
- void this.webBrowserExtensionInteractionService.openExtension();
+ async openExtension() {
+ await this.webBrowserExtensionInteractionService.openExtension().catch(() => {
+ this.state = SetupExtensionState.ManualOpen;
+
+ // Update the anon layout data to show the proper error design
+ this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
+ pageTitle: {
+ key: "somethingWentWrong",
+ },
+ pageIcon: VaultIcons.BrowserExtensionIcon,
+ hideIcon: false,
+ hideCardWrapper: false,
+ maxWidth: "md",
+ });
+ });
}
/** Update local state to never show this page again. */
diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html
index 20b87bfc036..006bf9ff197 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html
+++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html
@@ -93,73 +93,74 @@
-
-
-
-
-
- {{ "copyUsername" | i18n }}
-
-
-
- {{ "copyPassword" | i18n }}
-
-
-
- {{ "copyVerificationCode" | i18n }}
-
-
-
- {{ "launch" | i18n }}
-
-
-
-
-
- {{ "attachments" | i18n }}
-
-
-
- {{ "clone" | i18n }}
-
+ @if (!decryptionFailure && !hideMenu) {
-
- {{ "assignToCollections" | i18n }}
-
-
-
- {{ "eventLogs" | i18n }}
-
-
-
- {{ "restore" | i18n }}
-
-
-
-
- {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
-
-
-
+ appStopProp
+ appA11yTitle="{{ 'options' | i18n }}"
+ >
+
+
+
+
+ {{ "copyUsername" | i18n }}
+
+
+
+ {{ "copyPassword" | i18n }}
+
+
+
+ {{ "copyVerificationCode" | i18n }}
+
+
+
+ {{ "launch" | i18n }}
+
+
+
+
+
+ {{ "attachments" | i18n }}
+
+
+
+ {{ "clone" | i18n }}
+
+
+
+ {{ "assignToCollections" | i18n }}
+
+
+
+ {{ "eventLogs" | i18n }}
+
+
+
+ {{ "restore" | i18n }}
+
+
+
+
+ {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
+
+
+
+ }
diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts
index cb4d8ad70b1..32037493e36 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts
@@ -189,8 +189,14 @@ export class VaultCipherRowComponent implements OnInit
return this.i18nService.t("noAccess");
}
+ protected get showCopyUsername(): boolean {
+ const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
+ return this.isNotDeletedLoginCipher && usernameCopy;
+ }
+
protected get showCopyPassword(): boolean {
- return this.isNotDeletedLoginCipher && this.cipher.viewPassword;
+ const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
+ return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy;
}
protected get showCopyTotp(): boolean {
@@ -201,16 +207,20 @@ export class VaultCipherRowComponent implements OnInit
return this.isNotDeletedLoginCipher && this.canLaunch;
}
- protected get disableMenu() {
+ protected get isDeletedCanRestore(): boolean {
+ return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher;
+ }
+
+ protected get hideMenu() {
return !(
- this.isNotDeletedLoginCipher ||
+ this.isDeletedCanRestore ||
+ this.showCopyUsername ||
this.showCopyPassword ||
this.showCopyTotp ||
this.showLaunchUri ||
this.showAttachments ||
this.showClone ||
- this.canEditCipher ||
- (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher)
+ this.canEditCipher
);
}
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
index e63b353be9c..a8dd0056806 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
@@ -28,8 +28,8 @@ import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event";
// Fixed manual row height required due to how cdk-virtual-scroll works
-export const RowHeight = 75.5;
-export const RowHeightClass = `tw-h-[75.5px]`;
+export const RowHeight = 75;
+export const RowHeightClass = `tw-h-[75px]`;
const MaxSelectionCount = 500;
@@ -166,6 +166,10 @@ export class VaultItemsComponent {
);
}
+ clearSelection() {
+ this.selection.clear();
+ }
+
get showExtraColumn() {
return this.showCollections || this.showGroups || this.showOwner;
}
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts
index 78c4d21dede..c114cb6d7c2 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts
@@ -29,6 +29,7 @@ import {
} from "@bitwarden/common/platform/abstractions/environment.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -262,7 +263,7 @@ export const OrganizationTrash: Story = {
};
const unassignedCollection = new CollectionAdminView();
-unassignedCollection.id = Unassigned;
+unassignedCollection.id = Unassigned as CollectionId;
unassignedCollection.name = "Unassigned";
export const OrganizationTopLevelCollection: Story = {
args: {
@@ -327,7 +328,7 @@ function createCollectionView(i: number): CollectionAdminView {
const organization = organizations[i % (organizations.length + 1)];
const group = groups[i % (groups.length + 1)];
const view = new CollectionAdminView();
- view.id = `collection-${i}`;
+ view.id = `collection-${i}` as CollectionId;
view.name = `Collection ${i}`;
view.organizationId = organization?.id;
view.manage = true;
@@ -357,7 +358,7 @@ function createGroupView(i: number): GroupView {
function createOrganization(i: number): Organization {
const organization = new Organization();
- organization.id = `organization-${i}`;
+ organization.id = `organization-${i}` as OrganizationId;
organization.name = `Organization ${i}`;
organization.type = OrganizationUserType.Owner;
organization.permissions = new PermissionsApi();
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts
index a42b5228272..a5a99428b2d 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts
@@ -2,6 +2,8 @@ import { Injectable, OnDestroy } from "@angular/core";
import { ActivatedRoute, NavigationExtras } from "@angular/router";
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
+
import {
isRoutedVaultFilterItemType,
RoutedVaultFilterModel,
@@ -31,10 +33,12 @@ export class RoutedVaultFilterService implements OnDestroy {
const type = isRoutedVaultFilterItemType(unsafeType) ? unsafeType : undefined;
return {
- collectionId: queryParams.get("collectionId") ?? undefined,
+ collectionId: (queryParams.get("collectionId") as CollectionId) ?? undefined,
folderId: queryParams.get("folderId") ?? undefined,
organizationId:
- params.get("organizationId") ?? queryParams.get("organizationId") ?? undefined,
+ (params.get("organizationId") as OrganizationId) ??
+ (queryParams.get("organizationId") as OrganizationId) ??
+ undefined,
organizationIdParamType:
params.get("organizationId") != undefined ? ("path" as const) : ("query" as const),
type,
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts
index 266676e418b..11e074db985 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts
@@ -28,7 +28,7 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -209,7 +209,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected getOrganizationFilterMyVault(): TreeNode {
const myVault = new Organization() as OrganizationFilter;
- myVault.id = "MyVault";
+ myVault.id = "MyVault" as OrganizationId;
myVault.icon = "bwi-user";
myVault.enabled = true;
myVault.hideOptions = true;
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts
index fe236a089e0..02d536eb6ab 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts
@@ -1,4 +1,5 @@
import { Unassigned } from "@bitwarden/admin-console/common";
+import { CollectionId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
@@ -65,7 +66,7 @@ export class RoutedVaultFilterBridge implements VaultFilter {
let type: RoutedVaultFilterItemType | undefined;
if (value?.node.id === "AllItems" && this.routedFilter.organizationIdParamType === "path") {
- type = "all";
+ type = All;
} else if (
value?.node.id === "AllItems" &&
this.routedFilter.organizationIdParamType === "query"
@@ -98,7 +99,7 @@ export class RoutedVaultFilterBridge implements VaultFilter {
return this.legacyFilter.selectedCollectionNode;
}
set selectedCollectionNode(value: TreeNode) {
- let collectionId: string | undefined;
+ let collectionId: CollectionId | All | Unassigned | undefined;
if (value != null && value.node.id === null) {
collectionId = Unassigned;
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts
index 866ba1d9848..280ffd15732 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts
@@ -1,4 +1,11 @@
+import { Unassigned } from "@bitwarden/admin-console/common";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
+
+/**
+ * A constant used to represent viewing "all" of a particular filter.
+ */
export const All = "all";
+export type All = typeof All;
// TODO: Remove `All` when moving to vertical navigation.
const itemTypes = [
@@ -19,9 +26,9 @@ export function isRoutedVaultFilterItemType(value: unknown): value is RoutedVaul
}
export interface RoutedVaultFilterModel {
- collectionId?: string;
+ collectionId?: CollectionId | All | Unassigned;
folderId?: string;
- organizationId?: string;
+ organizationId?: OrganizationId | Unassigned;
type?: RoutedVaultFilterItemType;
organizationIdParamType?: "path" | "query";
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html
index c20209a0192..35b1a1876a1 100644
--- a/apps/web/src/app/vault/individual-vault/vault.component.html
+++ b/apps/web/src/app/vault/individual-vault/vault.component.html
@@ -37,6 +37,7 @@
{{ trashCleanupWarning }}
implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
+ @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent;
trashCleanupWarning: string = null;
kdfIterations: number;
@@ -1281,6 +1283,7 @@ export class VaultComponent implements OnInit, OnDestr
private refresh() {
this.refresh$.next();
+ this.vaultItemsComponent?.clearSelection();
}
private async go(queryParams: any = null) {
diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.ts
index 1f91942591b..ed5e2ef9948 100644
--- a/apps/web/src/app/vault/services/web-browser-interaction.service.ts
+++ b/apps/web/src/app/vault/services/web-browser-interaction.service.ts
@@ -25,7 +25,7 @@ import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum
* used to allow for the extension to open and then emit to the message.
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
*/
-const OPEN_RESPONSE_TIMEOUT_MS = 1500;
+const OPEN_RESPONSE_TIMEOUT_MS = 2000;
/**
* Timeout for checking if the extension is installed.
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 2a75bf51900..587dcd84e0c 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -1428,9 +1428,6 @@
"notificationSentDevicePart1": {
"message": "Unlock Bitwarden on your device or on the "
},
- "areYouTryingToAccessYourAccount": {
- "message": "Are you trying to access your account?"
- },
"accessAttemptBy": {
"message": "Access attempt by $EMAIL$",
"placeholders": {
@@ -3981,22 +3978,6 @@
"thisRequestIsNoLongerValid": {
"message": "This request is no longer valid."
},
- "logInConfirmedForEmailOnDevice": {
- "message": "Login confirmed for $EMAIL$ on $DEVICE$",
- "placeholders": {
- "email": {
- "content": "$1",
- "example": "name@example.com"
- },
- "device": {
- "content": "$2",
- "example": "iOS"
- }
- }
- },
- "youDeniedALogInAttemptFromAnotherDevice": {
- "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
- },
"loginRequestApprovedForEmailOnDevice": {
"message": "Login request approved for $EMAIL$ on $DEVICE$",
"placeholders": {
@@ -5448,6 +5429,37 @@
"organizationDataOwnership": {
"message": "Enforce organization data ownership"
},
+ "organizationDataOwnershipDesc": {
+ "message": "Require all items to be owned by an organization, removing the option to store items at the account level.",
+ "description": "This is the policy description shown in the policy list."
+ },
+ "organizationDataOwnershipContent": {
+ "message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
+ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
+ },
+ "organizationDataOwnershipContentAnchor":{
+ "message": "credential lifecycle",
+ "description": "This will be used as a hyperlink"
+ },
+ "organizationDataOwnershipWarningTitle":{
+ "message": "Are you sure you want to proceed?"
+ },
+ "organizationDataOwnershipWarning1":{
+ "message": "will remain accessible to members"
+ },
+ "organizationDataOwnershipWarning2":{
+ "message": "will not be automatically selected when creating new items"
+ },
+ "organizationDataOwnershipWarning3":{
+ "message": "cannot be managed from the Admin Console until the user is offboarded"
+ },
+ "organizationDataOwnershipWarningContentTop":{
+ "message": "By turning this policy off, the default collection: "
+ },
+ "organizationDataOwnershipWarningContentBottom":{
+ "message": "Learn more about the ",
+ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
+ },
"personalOwnership": {
"message": "Remove individual vault"
},
@@ -10984,5 +10996,11 @@
},
"unlimitedSecretsAndProjects": {
"message": "Unlimited secrets and projects"
+ },
+ "providersubscriptionCanceled": {
+ "message": "Subscription canceled"
+ },
+ "providersubCanceledmessage": {
+ "message" : "To resubscribe, contact Bitwarden Customer Support."
}
}
\ No newline at end of file
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts
index 61e2133d059..821509b43e2 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts
@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
+import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
BasePolicy,
BasePolicyComponent,
@@ -13,8 +15,8 @@ export class ActivateAutofillPolicy extends BasePolicy {
type = PolicyType.ActivateAutofill;
component = ActivateAutofillPolicyComponent;
- display(organization: Organization) {
- return organization.useActivateAutofillPolicy;
+ display(organization: Organization, configService: ConfigService) {
+ return of(organization.useActivateAutofillPolicy);
}
}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts
index 9cbe8115008..69d02214717 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts
@@ -52,8 +52,8 @@ export class MembersComponent extends BaseMembersComponent {
dataSource = new MembersTableDataSource();
loading = true;
providerId: string;
- rowHeight = 69;
- rowHeightClass = `tw-h-[69px]`;
+ rowHeight = 70;
+ rowHeightClass = `tw-h-[70px]`;
status: ProviderUserStatusType = null;
userStatusType = ProviderUserStatusType;
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
index 0fc49067740..52260168d4c 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts
@@ -12,9 +12,12 @@ import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-consol
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
-import { Icon, IconModule } from "@bitwarden/components";
-import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon";
-import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
+import {
+ Icon,
+ IconModule,
+ ProviderPortalLogo,
+ BusinessUnitPortalLogo,
+} from "@bitwarden/components";
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service";
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts
index 974dc9c460f..f9ff006de24 100644
--- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts
+++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts
@@ -158,7 +158,7 @@ export class ProviderSubscriptionStatusComponent {
}
case "incomplete_expired":
case "canceled": {
- const canceledText = this.i18nService.t("canceled");
+ const canceledText = this.i18nService.t("providersubscriptionCanceled");
return {
status: {
label: defaultStatusLabel,
@@ -171,7 +171,7 @@ export class ProviderSubscriptionStatusComponent {
callout: {
severity: "danger",
header: canceledText,
- body: this.i18nService.t("subscriptionCanceled"),
+ body: this.i18nService.t("providersubCanceledmessage"),
},
};
}
diff --git a/libs/admin-console/src/common/collections/models/collection-admin.view.ts b/libs/admin-console/src/common/collections/models/collection-admin.view.ts
index dd7a57013ca..dcc88551551 100644
--- a/libs/admin-console/src/common/collections/models/collection-admin.view.ts
+++ b/libs/admin-console/src/common/collections/models/collection-admin.view.ts
@@ -4,7 +4,10 @@ import { CollectionAccessSelectionView } from "./collection-access-selection.vie
import { CollectionAccessDetailsResponse } from "./collection.response";
import { CollectionView } from "./collection.view";
+// TODO: this is used to represent the pseudo "Unassigned" collection as well as
+// the user's personal vault (as a pseudo organization). This should be separated out into different values.
export const Unassigned = "unassigned";
+export type Unassigned = typeof Unassigned;
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
diff --git a/libs/admin-console/src/common/collections/models/collection.spec.ts b/libs/admin-console/src/common/collections/models/collection.spec.ts
index 925490d22b9..fb38f1507f9 100644
--- a/libs/admin-console/src/common/collections/models/collection.spec.ts
+++ b/libs/admin-console/src/common/collections/models/collection.spec.ts
@@ -54,7 +54,7 @@ describe("Collection", () => {
it("Decrypt", async () => {
const collection = new Collection();
- collection.id = "id";
+ collection.id = "id" as CollectionId;
collection.organizationId = "orgId" as OrganizationId;
collection.name = mockEnc("encName");
collection.externalId = "extId";
diff --git a/libs/admin-console/src/common/collections/models/collection.ts b/libs/admin-console/src/common/collections/models/collection.ts
index 7bbd018fa96..d1709d1751d 100644
--- a/libs/admin-console/src/common/collections/models/collection.ts
+++ b/libs/admin-console/src/common/collections/models/collection.ts
@@ -1,5 +1,6 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
@@ -13,8 +14,8 @@ export const CollectionTypes = {
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
- id: string | undefined;
- organizationId: string | undefined;
+ id: CollectionId | undefined;
+ organizationId: OrganizationId | undefined;
name: EncString | undefined;
externalId: string | undefined;
readOnly: boolean = false;
diff --git a/libs/admin-console/src/common/collections/models/collection.view.ts b/libs/admin-console/src/common/collections/models/collection.view.ts
index f75ff565100..3a60320856d 100644
--- a/libs/admin-console/src/common/collections/models/collection.view.ts
+++ b/libs/admin-console/src/common/collections/models/collection.view.ts
@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { View } from "@bitwarden/common/models/view/view";
+import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType, CollectionTypes } from "./collection";
@@ -10,8 +11,8 @@ import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
- id: string | undefined;
- organizationId: string | undefined;
+ id: CollectionId | undefined;
+ organizationId: OrganizationId | undefined;
name: string = "";
externalId: string | undefined;
// readOnly applies to the items within a collection
diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts
index 35e05602838..4523c3afebc 100644
--- a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts
+++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts
@@ -1,3 +1,5 @@
+import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
+
type OrganizationUserBulkRequestEntry = {
id: string;
key: string;
@@ -5,8 +7,10 @@ type OrganizationUserBulkRequestEntry = {
export class OrganizationUserBulkConfirmRequest {
keys: OrganizationUserBulkRequestEntry[];
+ defaultUserCollectionName: SdkEncString | undefined;
- constructor(keys: OrganizationUserBulkRequestEntry[]) {
+ constructor(keys: OrganizationUserBulkRequestEntry[], defaultUserCollectionName?: SdkEncString) {
this.keys = keys;
+ this.defaultUserCollectionName = defaultUserCollectionName;
}
}
diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts
index 62468a18225..864712ceb78 100644
--- a/libs/angular/src/auth/device-management/device-management-item-group.component.ts
+++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts
@@ -2,13 +2,12 @@ import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } 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 { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
+import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
+
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
@@ -29,7 +28,7 @@ export class DeviceManagementItemGroupComponent {
return;
}
- const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
+ const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts
index 1d20e54deec..c3c835f05ed 100644
--- a/libs/angular/src/auth/device-management/device-management-table.component.ts
+++ b/libs/angular/src/auth/device-management/device-management-table.component.ts
@@ -3,9 +3,6 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-// 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 { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
@@ -17,6 +14,8 @@ import {
TableModule,
} from "@bitwarden/components";
+import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
+
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
@@ -68,7 +67,7 @@ export class DeviceManagementTableComponent implements OnChanges {
return;
}
- const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
+ const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts
new file mode 100644
index 00000000000..018b1ce2547
--- /dev/null
+++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts
@@ -0,0 +1,30 @@
+import { TestBed } from "@angular/core/testing";
+
+import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service";
+import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
+
+describe("DefaultLoginApprovalDialogComponentService", () => {
+ let service: DefaultLoginApprovalDialogComponentService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [DefaultLoginApprovalDialogComponentService],
+ });
+
+ service = TestBed.inject(DefaultLoginApprovalDialogComponentService);
+ });
+
+ it("is created successfully", () => {
+ expect(service).toBeTruthy();
+ });
+
+ it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
+ const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent;
+
+ const result = await service.showLoginRequestedAlertIfWindowNotVisible(
+ loginApprovalDialogComponent.email,
+ );
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts
new file mode 100644
index 00000000000..5fefd3c3abb
--- /dev/null
+++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts
@@ -0,0 +1,16 @@
+import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
+
+/**
+ * Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
+ */
+export class DefaultLoginApprovalDialogComponentService
+ implements LoginApprovalDialogComponentServiceAbstraction
+{
+ /**
+ * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
+ * @returns
+ */
+ async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise {
+ return;
+ }
+}
diff --git a/libs/angular/src/auth/login-approval/index.ts b/libs/angular/src/auth/login-approval/index.ts
new file mode 100644
index 00000000000..7b34b17d56b
--- /dev/null
+++ b/libs/angular/src/auth/login-approval/index.ts
@@ -0,0 +1,3 @@
+export * from "./login-approval-dialog.component";
+export * from "./login-approval-dialog-component.service.abstraction";
+export * from "./default-login-approval-dialog-component.service";
diff --git a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts b/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts
similarity index 57%
rename from libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts
rename to libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts
index eaa62359808..f29311402a7 100644
--- a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts
+++ b/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts
@@ -1,7 +1,7 @@
/**
- * Abstraction for the LoginApprovalComponent service.
+ * Abstraction for the LoginApprovalDialogComponent service.
*/
-export abstract class LoginApprovalComponentServiceAbstraction {
+export abstract class LoginApprovalDialogComponentServiceAbstraction {
/**
* Shows a login requested alert if the window is not visible.
*/
diff --git a/libs/auth/src/angular/login-approval/login-approval.component.html b/libs/angular/src/auth/login-approval/login-approval-dialog.component.html
similarity index 72%
rename from libs/auth/src/angular/login-approval/login-approval.component.html
rename to libs/angular/src/auth/login-approval/login-approval-dialog.component.html
index d37e30c5e0a..f2850406235 100644
--- a/libs/auth/src/angular/login-approval/login-approval.component.html
+++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.html
@@ -1,5 +1,6 @@
- {{ "areYouTryingToAccessYourAccount" | i18n }}
+ {{ "loginRequest" | i18n }}
+
@@ -8,28 +9,29 @@
- {{ "accessAttemptBy" | i18n: email }}
+ {{ "accessAttemptBy" | i18n: email }}
-
{{ "fingerprintPhraseHeader" | i18n }}
+
{{ "fingerprintPhraseHeader" | i18n }}
{{ fingerprintPhrase }}
-
{{ "deviceType" | i18n }}
-
{{ authRequestResponse?.requestDeviceType }}
+
{{ "deviceType" | i18n }}
+
{{ readableDeviceTypeName }}
-
{{ "location" | i18n }}
+
{{ "location" | i18n }}
{{ authRequestResponse?.requestCountryName }}
({{ authRequestResponse?.requestIpAddress }})
-
{{ "time" | i18n }}
+
{{ "time" | i18n }}
{{ requestTimeText }}
+
{
- let component: LoginApprovalComponent;
- let fixture: ComponentFixture;
+describe("LoginApprovalDialogComponent", () => {
+ let component: LoginApprovalDialogComponent;
+ let fixture: ComponentFixture;
- let authRequestService: MockProxy;
let accountService: MockProxy;
let apiService: MockProxy;
- let i18nService: MockProxy;
+ let authRequestService: MockProxy;
+ let devicesService: MockProxy;
let dialogRef: MockProxy;
+ let i18nService: MockProxy;
+ let logService: MockProxy;
let toastService: MockProxy;
let validationService: MockProxy;
@@ -38,11 +37,13 @@ describe("LoginApprovalComponent", () => {
const testPublicKey = "test-public-key";
beforeEach(async () => {
- authRequestService = mock();
accountService = mock();
apiService = mock();
- i18nService = mock();
+ authRequestService = mock();
+ devicesService = mock();
dialogRef = mock();
+ i18nService = mock();
+ logService = mock();
toastService = mock();
validationService = mock();
@@ -54,27 +55,26 @@ describe("LoginApprovalComponent", () => {
});
await TestBed.configureTestingModule({
- imports: [LoginApprovalComponent],
+ imports: [LoginApprovalDialogComponent],
providers: [
{ provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } },
- { provide: AuthRequestServiceAbstraction, useValue: authRequestService },
{ provide: AccountService, useValue: accountService },
- { provide: PlatformUtilsService, useValue: mock() },
- { provide: I18nService, useValue: i18nService },
{ provide: ApiService, useValue: apiService },
- { provide: AppIdService, useValue: mock() },
- { provide: KeyService, useValue: mock() },
+ { provide: AuthRequestServiceAbstraction, useValue: authRequestService },
+ { provide: DevicesServiceAbstraction, useValue: devicesService },
{ provide: DialogRef, useValue: dialogRef },
+ { provide: I18nService, useValue: i18nService },
+ { provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService },
{ provide: ValidationService, useValue: validationService },
{
- provide: LoginApprovalComponentServiceAbstraction,
- useValue: mock(),
+ provide: LoginApprovalDialogComponentServiceAbstraction,
+ useValue: mock(),
},
],
}).compileComponents();
- fixture = TestBed.createComponent(LoginApprovalComponent);
+ fixture = TestBed.createComponent(LoginApprovalDialogComponent);
component = fixture.componentInstance;
});
@@ -119,7 +119,6 @@ describe("LoginApprovalComponent", () => {
expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "info",
- title: null,
message: "denied message",
});
});
diff --git a/libs/auth/src/angular/login-approval/login-approval.component.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts
similarity index 56%
rename from libs/auth/src/angular/login-approval/login-approval.component.ts
rename to libs/angular/src/auth/login-approval/login-approval-dialog.component.ts
index 285bdd0ddf0..19dc3f519c6 100644
--- a/libs/auth/src/angular/login-approval/login-approval.component.ts
+++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts
@@ -1,24 +1,18 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, Inject } from "@angular/core";
-import { Subject, firstValueFrom, map } from "rxjs";
+import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import {
- AuthRequestServiceAbstraction,
- LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService,
-} from "@bitwarden/auth/common";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
-import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
-import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
-import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
-import { Utils } from "@bitwarden/common/platform/misc/utils";
// 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 { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
+import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
DIALOG_DATA,
DialogRef,
@@ -28,84 +22,100 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
-import { KeyService } from "@bitwarden/key-management";
+import { LogService } from "@bitwarden/logging";
-const RequestTimeOut = 60000 * 15; //15 Minutes
-const RequestTimeUpdate = 60000 * 5; //5 Minutes
+import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
+
+const RequestTimeOut = 60000 * 15; // 15 Minutes
+const RequestTimeUpdate = 60000 * 5; // 5 Minutes
export interface LoginApprovalDialogParams {
notificationId: string;
}
@Component({
- selector: "login-approval",
- templateUrl: "login-approval.component.html",
- imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
+ templateUrl: "login-approval-dialog.component.html",
+ imports: [AsyncActionsModule, ButtonModule, CommonModule, DialogModule, JslibModule],
})
-export class LoginApprovalComponent implements OnInit, OnDestroy {
+export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
+ authRequestId: string;
+ authRequestResponse?: AuthRequestResponse;
+ email?: string;
+ fingerprintPhrase?: string;
+ interval?: NodeJS.Timeout;
loading = true;
-
- notificationId: string;
-
- private destroy$ = new Subject();
-
- email: string;
- fingerprintPhrase: string;
- authRequestResponse: AuthRequestResponse;
- interval: NodeJS.Timeout;
- requestTimeText: string;
+ readableDeviceTypeName?: string;
+ requestTimeText?: string;
constructor(
@Inject(DIALOG_DATA) private params: LoginApprovalDialogParams,
- protected authRequestService: AuthRequestServiceAbstraction,
- protected accountService: AccountService,
- protected platformUtilsService: PlatformUtilsService,
- protected i18nService: I18nService,
- protected apiService: ApiService,
- protected appIdService: AppIdService,
- protected keyService: KeyService,
+ private accountService: AccountService,
+ private apiService: ApiService,
+ private authRequestService: AuthRequestServiceAbstraction,
+ private devicesService: DevicesServiceAbstraction,
private dialogRef: DialogRef,
+ private i18nService: I18nService,
+ private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction,
+ private logService: LogService,
private toastService: ToastService,
- private loginApprovalComponentService: LoginApprovalComponentService,
private validationService: ValidationService,
) {
- this.notificationId = params.notificationId;
+ this.authRequestId = params.notificationId;
}
async ngOnDestroy(): Promise {
clearInterval(this.interval);
- this.destroy$.next();
- this.destroy$.complete();
}
async ngOnInit() {
- if (this.notificationId != null) {
- try {
- this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
- } catch (error) {
- this.validationService.showError(error);
- }
-
- const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
- this.email = await firstValueFrom(
- this.accountService.activeAccount$.pipe(map((a) => a?.email)),
- );
- this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
- this.email,
- publicKey,
- );
- this.updateTimeText();
-
- this.interval = setInterval(() => {
- this.updateTimeText();
- }, RequestTimeUpdate);
-
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
-
- this.loading = false;
+ if (this.authRequestId == null) {
+ this.logService.error("LoginApprovalDialogComponent: authRequestId is null");
+ return;
}
+
+ try {
+ this.authRequestResponse = await this.apiService.getAuthRequest(this.authRequestId);
+ } catch (error) {
+ this.validationService.showError(error);
+ this.logService.error("LoginApprovalDialogComponent: getAuthRequest error", error);
+ }
+
+ if (this.authRequestResponse == null) {
+ this.logService.error("LoginApprovalDialogComponent: authRequestResponse not found");
+ return;
+ }
+
+ const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
+
+ this.email = await firstValueFrom(
+ this.accountService.activeAccount$.pipe(map((a) => a?.email)),
+ );
+
+ if (!this.email) {
+ this.logService.error("LoginApprovalDialogComponent: email not found");
+ return;
+ }
+
+ this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
+ this.email,
+ publicKey,
+ );
+
+ this.readableDeviceTypeName = this.devicesService.getReadableDeviceTypeName(
+ this.authRequestResponse.requestDeviceTypeValue,
+ );
+
+ this.updateTimeText();
+
+ this.interval = setInterval(() => {
+ this.updateTimeText();
+ }, RequestTimeUpdate);
+
+ await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible(
+ this.email,
+ );
+
+ this.loading = false;
}
/**
@@ -114,7 +124,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
* @param data Configuration for the dialog
*/
static open(dialogService: DialogService, data: LoginApprovalDialogParams) {
- return dialogService.open(LoginApprovalComponent, { data });
+ return dialogService.open(LoginApprovalDialogComponent, { data });
}
denyLogin = async () => {
@@ -126,11 +136,10 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
};
private async retrieveAuthRequestAndRespond(approve: boolean) {
- this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
+ this.authRequestResponse = await this.apiService.getAuthRequest(this.authRequestId);
if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) {
this.toastService.showToast({
variant: "info",
- title: null,
message: this.i18nService.t("thisRequestIsNoLongerValid"),
});
} else {
@@ -148,23 +157,26 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
if (loginResponse.requestApproved) {
this.toastService.showToast({
variant: "success",
- title: null,
message: this.i18nService.t(
- "logInConfirmedForEmailOnDevice",
+ "loginRequestApprovedForEmailOnDevice",
this.email,
- loginResponse.requestDeviceType,
+ this.devicesService.getReadableDeviceTypeName(loginResponse.requestDeviceTypeValue),
),
});
} else {
this.toastService.showToast({
variant: "info",
- title: null,
- message: this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice"),
+ message: this.i18nService.t("youDeniedLoginAttemptFromAnotherDevice"),
});
}
}
updateTimeText() {
+ if (this.authRequestResponse == null) {
+ this.logService.error("LoginApprovalDialogComponent: authRequestResponse not found");
+ return;
+ }
+
const requestDate = new Date(this.authRequestResponse.creationDate);
const requestDateUTC = Date.UTC(
requestDate.getUTCFullYear(),
@@ -201,7 +213,6 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
this.dialogRef.close();
this.toastService.showToast({
variant: "info",
- title: null,
message: this.i18nService.t("loginRequestHasAlreadyExpired"),
});
}
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 2eda60984b4..0a64c3a90cf 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -18,7 +18,6 @@ import {
// 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 {
- DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
DefaultRegistrationFinishService,
@@ -40,7 +39,6 @@ import {
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
- LoginApprovalComponentServiceAbstraction,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
@@ -345,6 +343,8 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
+import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service";
+import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction";
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
@@ -539,6 +539,7 @@ const safeProviders: SafeProvider[] = [
accountService: AccountServiceAbstraction,
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
+ messagingService: MessagingServiceAbstraction,
) =>
new CipherService(
keyService,
@@ -555,6 +556,7 @@ const safeProviders: SafeProvider[] = [
accountService,
logService,
cipherEncryptionService,
+ messagingService,
),
deps: [
KeyService,
@@ -571,6 +573,7 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
LogService,
CipherEncryptionService,
+ MessagingServiceAbstraction,
],
}),
safeProvider({
@@ -1501,8 +1504,8 @@ const safeProviders: SafeProvider[] = [
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
- provide: LoginApprovalComponentServiceAbstraction,
- useClass: DefaultLoginApprovalComponentService,
+ provide: LoginApprovalDialogComponentServiceAbstraction,
+ useClass: DefaultLoginApprovalDialogComponentService,
deps: [],
}),
safeProvider({
diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html
index 29d13d2056c..e445640cff9 100644
--- a/libs/angular/src/vault/components/spotlight/spotlight.component.html
+++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html
@@ -1,5 +1,5 @@