1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-03-11 11:28:01 -05:00
committed by GitHub
94 changed files with 1920 additions and 1283 deletions

1
.github/CODEOWNERS vendored
View File

@@ -119,6 +119,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/src/autofill @bitwarden/team-autofill-dev
apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev

View File

@@ -385,6 +385,15 @@
"editFolder": {
"message": "Edit folder"
},
"editFolderWithName": {
"message": "Edit folder: $FOLDERNAME$",
"placeholders": {
"foldername": {
"content": "$1",
"example": "Social"
}
}
},
"newFolder": {
"message": "New folder"
},
@@ -1670,6 +1679,9 @@
"dragToSort": {
"message": "Drag to sort"
},
"dragToReorder": {
"message": "Drag to reorder"
},
"cfTypeText": {
"message": "Text"
},
@@ -4697,6 +4709,9 @@
}
}
},
"reorderWebsiteUriButton": {
"message": "Reorder website URI. Use arrow key to move item up or down."
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()">
<header>
<div class="left">
<button type="button" routerLink="/home">{{ "close" | i18n }}</button>
<button type="button" routerLink="/login">{{ "close" | i18n }}</button>
</div>
<h1 class="center">
<span class="title">{{ "appName" | i18n }}</span>

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<header>
<div class="left">
<button type="button" routerLink="/home">{{ "cancel" | i18n }}</button>
<button type="button" routerLink="/login">{{ "cancel" | i18n }}</button>
</div>
<h1 class="center">
<span class="title">{{ "setMasterPassword" | i18n }}</span>

View File

@@ -72,7 +72,7 @@ describe("AuthPopoutWindow", () => {
it("closes any existing popup window types that are open to the login extension route", async () => {
const loginTab = createChromeTabMock({
url: chrome.runtime.getURL("popup/index.html#/home"),
url: chrome.runtime.getURL("popup/index.html#/login"),
});
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]);
jest.spyOn(BrowserApi, "removeWindow");

View File

@@ -13,7 +13,7 @@ const AuthPopoutType = {
const extensionUnlockUrls = new Set([
chrome.runtime.getURL("popup/index.html#/lock"),
chrome.runtime.getURL("popup/index.html#/home"),
chrome.runtime.getURL("popup/index.html#/login"),
]);
/**

View File

@@ -7,7 +7,6 @@ import {
EnvironmentSelectorRouteData,
ExtensionDefaultOverlayPosition,
} from "@bitwarden/angular/auth/components/environment-selector.component";
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
activeAuthGuard,
@@ -58,15 +57,9 @@ import {
ExtensionAnonLayoutWrapperComponent,
ExtensionAnonLayoutWrapperData,
} from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
import { HintComponent } from "../auth/popup/hint.component";
import { HomeComponent } from "../auth/popup/home.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/popup/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component";
import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
@@ -131,20 +124,19 @@ const routes: Routes = [
children: [], // Children lets us have an empty component.
canActivate: [
popupRouterCacheGuard,
redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }),
redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/login", locked: "/lock" }),
],
},
{
path: "home",
redirectTo: "login",
pathMatch: "full",
},
{
path: "vault",
redirectTo: "/tabs/vault",
pathMatch: "full",
},
{
path: "home",
component: HomeComponent,
canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "fido2",
component: Fido2Component,
@@ -206,40 +198,6 @@ const routes: Routes = [
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties,
},
...unauthUiRefreshSwap(
SsoComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
),
{
path: "device-verification",
component: ExtensionAnonLayoutWrapperComponent,
@@ -420,158 +378,7 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login-with-device",
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
HintComponent,
ExtensionAnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
elevation: 1,
} satisfies RouteDataProperties,
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 },
},
{
path: "",
children: [
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "logInToBitwarden",
},
elevation: 1,
showAcctSwitcher: true,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
@@ -597,7 +404,7 @@ const routes: Routes = [
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/home",
loginRoute: "/login",
} satisfies RegistrationStartSecondaryComponentData,
},
],
@@ -617,6 +424,127 @@ const routes: Routes = [
},
],
},
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "logInToBitwarden",
},
elevation: 1,
showAcctSwitcher: true,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "lock",
canActivate: [lockGuard()],

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -68,7 +70,10 @@ export class AppComponent implements OnInit, OnDestroy {
private animationControlService: AnimationControlService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
async ngOnInit() {
initPopupClosedListener();
@@ -113,9 +118,7 @@ export class AppComponent implements OnInit, OnDestroy {
});
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked" || msg.command === "goHome") {
// 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.router.navigate(["home"]);
await this.router.navigate(["login"]);
} else if (
msg.command === "locked" &&
(msg.userId == null || msg.userId == this.activeUserId)

View File

@@ -17,6 +17,7 @@
<a
tabIndex="0"
bitLink
class="tw-font-bold"
linkType="primary"
routerLink="/appearance"
(keydown.enter)="goToAppearance()"

View File

@@ -19,7 +19,7 @@
slot="end"
type="button"
(click)="openAddEditFolderDialog(folder)"
[appA11yTitle]="'editFolder' | i18n"
[appA11yTitle]="'editFolderWithName' | i18n: folder.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
data-testid="edit-folder-button"

View File

@@ -51,17 +51,13 @@ import {
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { HintComponent } from "../auth/hint.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/login/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { SendComponent } from "./tools/send/send.component";
/**
@@ -167,33 +163,6 @@ const routes: Routes = [
},
{ path: "accessibility-cookie", component: AccessibilityCookieComponent },
{ path: "set-password", component: SetPasswordComponent },
...unauthUiRefreshSwap(
SsoComponentV1,
AnonLayoutWrapperComponent,
{
path: "sso",
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
{
path: "send",
component: SendComponent,
@@ -209,139 +178,10 @@ const routes: Routes = [
component: RemovePasswordComponent,
canActivate: [authGuard],
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
HintComponent,
AnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn()],
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginComponentV1,
AnonLayoutWrapperComponent,
{
path: "login",
component: LoginComponentV1,
canActivate: [maxAccountsGuardFn()],
},
{
path: "",
children: [
{
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "",
component: AnonLayoutWrapperComponent,
@@ -383,6 +223,110 @@ const routes: Routes = [
},
],
},
{
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "lock",
canActivate: [lockGuard()],

View File

@@ -10,10 +10,12 @@ import {
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { filter, firstValueFrom, map, Subject, takeUntil, timeout, withLatestFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
@@ -157,7 +159,10 @@ export class AppComponent implements OnInit, OnDestroy {
private stateEventRunnerService: StateEventRunnerService,
private accountService: AccountService,
private organizationService: OrganizationService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {

View File

@@ -46,8 +46,6 @@ import { HeaderComponent } from "./layout/header.component";
import { NavComponent } from "./layout/nav.component";
import { SearchComponent } from "./layout/search/search.component";
import { SharedModule } from "./shared/shared.module";
import { AddEditComponent as SendAddEditComponent } from "./tools/send/add-edit.component";
import { SendComponent } from "./tools/send/send.component";
@NgModule({
imports: [
@@ -60,6 +58,7 @@ import { SendComponent } from "./tools/send/send.component";
DeleteAccountComponent,
UserVerificationComponent,
DecryptionFailureDialogComponent,
NavComponent,
],
declarations: [
AccessibilityCookieComponent,
@@ -76,13 +75,10 @@ import { SendComponent } from "./tools/send/send.component";
FolderAddEditComponent,
HeaderComponent,
HintComponent,
NavComponent,
PasswordHistoryComponent,
PremiumComponent,
RemovePasswordComponent,
SearchComponent,
SendAddEditComponent,
SendComponent,
SetPasswordComponent,
SettingsComponent,
ShareComponent,

View File

@@ -0,0 +1,36 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Component({
standalone: true,
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="closeModal()"
>
Close
</button>
</div>
`,
})
export class Fido2PlaceholderComponent {
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly router: Router,
) {}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
}
}

View File

@@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Component({
selector: "app-nav",
templateUrl: "nav.component.html",
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
})
export class NavComponent {
items: any[] = [

View File

@@ -1,9 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DatePipe } from "@angular/common";
import { CommonModule, DatePipe } from "@angular/common";
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,11 +17,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
@Component({
selector: "app-send-add-edit",
templateUrl: "add-edit.component.html",
standalone: true,
imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule],
})
export class AddEditComponent extends BaseAddEditComponent {
constructor(

View File

@@ -1,7 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -17,6 +20,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService, ToastService } from "@bitwarden/components";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { NavComponent } from "../../layout/nav.component";
import { SearchBarService } from "../../layout/search/search-bar.service";
import { AddEditComponent } from "./add-edit.component";
@@ -32,6 +36,8 @@ const BroadcasterSubscriptionId = "SendComponent";
@Component({
selector: "app-send",
templateUrl: "send.component.html",
standalone: true,
imports: [CommonModule, JslibModule, FormsModule, NavComponent, AddEditComponent],
})
export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy {
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;

View File

@@ -284,6 +284,8 @@ export class Main {
this.migrationRunner.run().then(
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
@@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -49,6 +51,11 @@ export class TrayMain {
label: this.i18nService.t("showHide"),
click: () => this.toggleWindow(),
},
{
visible: isDev(),
label: "Fake Popup",
click: () => this.fakePopup(),
},
{ type: "separator" },
{
label: this.i18nService.t("exit"),
@@ -190,7 +197,7 @@ export class TrayMain {
this.hideDock();
}
} else {
this.windowMain.win.show();
this.windowMain.show();
if (this.isDarwin()) {
this.showDock();
}
@@ -203,4 +210,38 @@ export class TrayMain {
this.windowMain.win.close();
}
}
/**
* This method is used to test modal behavior during development and could be removed in the future.
* @returns
*/
private async fakePopup() {
if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
await this.windowMain.createWindow("modal-app");
return;
}
// Restyle existing
const existingWin = this.windowMain.win;
await this.desktopSettingsService.setInModalMode(true);
await existingWin.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(existingWin.webContents.userAgent),
},
);
existingWin.once("ready-to-show", () => {
existingWin.show();
});
}
}

View File

@@ -5,7 +5,7 @@ import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
import { firstValueFrom } from "rxjs";
import { concatMap, firstValueFrom, pairwise } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -14,6 +14,7 @@ import { processisolations } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management";
import { WindowState } from "../platform/models/domain/window-state";
import { applyMainWindowStyles, applyPopupModalStyles } from "../platform/popup-modal-styles";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils";
@@ -77,6 +78,24 @@ export class WindowMain {
}
});
this.desktopSettingsService.inModalMode$
.pipe(
pairwise(),
concatMap(async ([lastValue, newValue]) => {
if (lastValue && !newValue) {
// Reset the window state to the main window state
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue && newValue) {
// Apply the popup modal styles
applyPopupModalStyles(this.win);
this.win.show();
}
}),
)
.subscribe();
this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => {
if (this.win == null) {
return;
@@ -182,7 +201,20 @@ export class WindowMain {
});
}
async createWindow(): Promise<void> {
/// Show the window with main window styles
show() {
if (this.win != null) {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
this.win.show();
}
}
/**
* Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
* When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
* TODO: We might want to refactor the template argument to accomodate more target pages, e.g. ssh-agent.
*/
async createWindow(template: "full-app" | "modal-app" = "full-app"): Promise<void> {
this.windowStates[mainWindowSizeKey] = await this.getWindowState(
this.defaultWidth,
this.defaultHeight,
@@ -216,6 +248,12 @@ export class WindowMain {
},
});
if (template === "modal-app") {
applyPopupModalStyles(this.win);
} else {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
}
this.win.webContents.on("dom-ready", () => {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
});
@@ -225,21 +263,41 @@ export class WindowMain {
}
// Show it later since it might need to be maximized.
this.win.show();
// use once event to avoid flash on unstyled content.
this.win.once("ready-to-show", () => {
this.win.show();
});
// and load the index.html of the app.
// 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.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
if (template === "full-app") {
// and load the index.html of the app.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
void this.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
} else {
// we're in modal mode - load the passkeys page
await this.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
}
// Open the DevTools.
if (isDev()) {
@@ -336,6 +394,12 @@ export class WindowMain {
return;
}
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
if (inModalMode) {
return;
}
try {
const bounds = win.getBounds();
@@ -346,9 +410,14 @@ export class WindowMain {
}
}
this.windowStates[configKey].isMaximized = win.isMaximized();
// We treat fullscreen as maximized (would be even better to store isFullscreen as its own flag).
this.windowStates[configKey].isMaximized = win.isMaximized() || win.isFullScreen();
this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
// Maybe store these as well?
// win.isFocused();
// win.isVisible();
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
this.windowStates[configKey].x = bounds.x;
this.windowStates[configKey].y = bounds.y;

View File

@@ -0,0 +1,52 @@
import { BrowserWindow } from "electron";
import { WindowState } from "./models/domain/window-state";
// change as needed, however limited by mainwindow minimum size
const popupWidth = 680;
const popupHeight = 500;
export function applyPopupModalStyles(window: BrowserWindow) {
window.unmaximize();
window.setSize(popupWidth, popupHeight);
window.center();
window.setWindowButtonVisibility?.(false);
window.setMenuBarVisibility?.(false);
window.setResizable(false);
window.setAlwaysOnTop(true);
// Adjusting from full screen is a bit more hassle
if (window.isFullScreen()) {
window.setFullScreen(false);
window.once("leave-full-screen", () => {
window.setSize(popupWidth, popupHeight);
window.center();
});
}
}
export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
window.setMinimumSize(680, 500);
// need to guard against null/undefined values
if (existingWindowState?.width && existingWindowState?.height) {
window.setSize(existingWindowState.width, existingWindowState.height);
}
if (existingWindowState?.x && existingWindowState?.y) {
window.setPosition(existingWindowState.x, existingWindowState.y);
}
window.setWindowButtonVisibility?.(true);
window.setMenuBarVisibility?.(true);
window.setResizable(true);
window.setAlwaysOnTop(false);
// We're currently not recovering the maximized state, mostly due to conflicts with hiding the window.
// window.setFullScreen(existingWindowState.isMaximized);
// if (existingWindowState.isMaximized) {
// window.maximize();
// }
}

View File

@@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
deserializer: (b) => b,
});
const PREVENT_SCREENSHOTS = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK,
"preventScreenshots",
@@ -170,6 +174,10 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
map((window) =>
@@ -178,6 +186,14 @@ export class DesktopSettingsService {
);
}
/**
* This is used to clear the setting on application start to make sure we don't end up
* stuck in modal mode if the application is force-closed in modal mode.
*/
async resetInModalMode() {
await this.inModalModeState.update(() => false);
}
async setHardwareAcceleration(enabled: boolean) {
await this.hwState.update(() => enabled);
}
@@ -286,6 +302,14 @@ export class DesktopSettingsService {
await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value);
}
/**
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
* @param value `true` if the application is in modal mode, `false` if it is not.
*/
async setInModalMode(value: boolean) {
await this.inModalModeState.update(() => value);
}
/**
* Sets the setting for whether or not the screenshot protection is enabled.
* @param value `true` if the screenshot protection is enabled, `false` if it is not.

View File

@@ -84,7 +84,7 @@
</ng-container>
<small *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -45,22 +45,16 @@
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="row">
<div class="col-3" *ngIf="!hideVaultFilters">
<div class="groupings">
<div class="content">
<div class="inner-content">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
</div>
</div>
<div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'col-12' : 'col-9'">
<div [class]="hideVaultFilters ? 'tw-w-4/5' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
@@ -140,7 +134,7 @@
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -124,7 +124,7 @@
buttonType="primary"
[disabled]="loading || dialogReadonly"
>
{{ "save" | i18n }}
{{ buttonDisplayName | i18n }}
</button>
<button
type="button"

View File

@@ -24,6 +24,8 @@ import {
OrganizationUserUserMiniResponse,
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -32,13 +34,17 @@ import {
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 { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SelectModule, BitValidators, DialogService, ToastService } from "@bitwarden/components";
import { openChangePlanDialog } from "../../../../../billing/organizations/change-plan-dialog.component";
import { SharedModule } from "../../../../../shared";
import { GroupApiService, GroupView } from "../../../core";
import { freeOrgCollectionLimitValidator } from "../../validators/free-org-collection-limit.validator";
import { PermissionMode } from "../access-selector/access-selector.component";
import {
AccessItemType,
@@ -55,6 +61,19 @@ export enum CollectionDialogTabType {
Access = 1,
}
/**
* Enum representing button labels for the "Add New Collection" dialog.
*
* @readonly
* @enum {string}
*/
enum ButtonType {
/** Displayed when the user has reached the maximum number of collections allowed for the organization. */
Upgrade = "upgrade",
/** Displayed when the user can still add more collections within the allowed limit. */
Save = "save",
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
@@ -78,6 +97,7 @@ export enum CollectionDialogAction {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
Upgrade = "upgrade",
}
@Component({
@@ -107,6 +127,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization;
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
@@ -122,6 +145,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private accountService: AccountService,
private toastService: ToastService,
private collectionService: CollectionService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
}
@@ -151,6 +176,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
await this.loadOrg(this.params.organizationId);
}
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
);
if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService),
);
this.formGroup.updateValueAndValidity();
}
this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => {
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async loadOrg(orgId: string) {
@@ -263,6 +305,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
get organizationSelected() {
return this.formGroup.controls.selectedOrg;
}
protected get collectionId() {
return this.params.collectionId;
}
@@ -287,6 +333,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.markAllAsTouched();
if (this.buttonDisplayName == ButtonType.Upgrade) {
this.close(CollectionDialogAction.Upgrade);
this.changePlan(this.orgExceedingCollectionLimit);
return;
}
if (this.formGroup.invalid) {
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
@@ -369,6 +421,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private changePlan(org: Organization) {
openChangePlanDialog(this.dialogService, {
data: {
organizationId: org.id,
subscription: null,
productTierType: org.productTierType,
},
});
}
private handleAddAccessWarning(): boolean {
if (
!this.organization?.allowAdminAccessToAllCollectionItems &&

View File

@@ -0,0 +1,78 @@
import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms";
import { lastValueFrom, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { freeOrgCollectionLimitValidator } from "./free-org-collection-limit.validator";
describe("freeOrgCollectionLimitValidator", () => {
let i18nService: I18nService;
beforeEach(() => {
i18nService = {
t: (key: string) => key,
} as any;
});
it("returns null if organization is not found", async () => {
const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService);
const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator(
control,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const result: Observable<ValidationErrors | null> = validator(
undefined as any,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toBeNull();
});
it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toEqual({
cannotCreateCollections: { message: "cannotCreateCollection" },
});
});
});

View File

@@ -0,0 +1,44 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>,
collections: Collection[],
i18nService: I18nService,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!(control instanceof FormControl)) {
return of(null);
}
const orgId = control.value;
if (!orgId) {
return of(null);
}
return orgs.pipe(
map((organizations) => organizations.find((org) => org.id === orgId)),
map((org) => {
if (!org) {
return null;
}
const orgCollections = collections.filter((c) => c.organizationId === org.id);
const hasReachedLimit = org.maxCollections === orgCollections.length;
if (hasReachedLimit) {
return {
cannotCreateCollections: { message: i18nService.t("cannotCreateCollection") },
};
}
return null;
}),
);
};
}

View File

@@ -2,11 +2,13 @@
// @ts-strict-ignore
import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery";
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -95,7 +97,10 @@ export class AppComponent implements OnDestroy, OnInit {
private apiService: ApiService,
private appIdService: AppIdService,
private processReloadService: ProcessReloadServiceAbstraction,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => {

View File

@@ -9,7 +9,13 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { CipherViewComponent, DefaultTaskService, TaskService } from "@bitwarden/vault";
import {
ChangeLoginPasswordService,
CipherViewComponent,
DefaultChangeLoginPasswordService,
DefaultTaskService,
TaskService,
} from "@bitwarden/vault";
import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service";
@@ -34,6 +40,7 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop },
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],
})
export class EmergencyViewDialogComponent {

View File

@@ -180,8 +180,20 @@ export class DeviceManagementComponent {
private updateDeviceTable(devices: Array<DeviceView>): void {
this.dataSource.data = devices
.map((device: DeviceView): DeviceTableData | null => {
if (!device.id || !device.type || !device.creationDate) {
this.validationService.showError(new Error("Invalid device data"));
if (device.id == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
if (device.type == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
return null;
}
if (device.creationDate == undefined) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);
return null;
}

View File

@@ -55,10 +55,6 @@ import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";
import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component";
import { deepLinkGuard } from "./auth/guards/deep-link.guard";
import { HintComponent } from "./auth/hint.component";
import { LoginDecryptionOptionsComponentV1 } from "./auth/login/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "./auth/login/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component";
import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component";
import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component";
import { RecoverDeleteComponent } from "./auth/recover-delete.component";
@@ -69,7 +65,6 @@ import { AccountComponent } from "./auth/settings/account/account.component";
import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component";
import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component";
import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module";
import { SsoComponentV1 } from "./auth/sso-v1.component";
import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component";
import { UpdatePasswordComponent } from "./auth/update-password.component";
import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component";
@@ -172,172 +167,6 @@ const routes: Routes = [
},
],
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
data: { titleId: "loginWithDevice" } satisfies RouteDataProperties,
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
titleId: "loginInitiated",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
data: { titleId: "adminApprovalRequested" } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
titleId: "adminApprovalRequested",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
AnonLayoutWrapperComponent,
AnonLayoutWrapperComponent,
{
path: "login",
canActivate: [unauthGuardFn()],
children: [
{
path: "",
component: LoginComponentV1,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: {
key: "logIn",
},
},
},
{
path: "login",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: LoginComponent,
},
{
path: "",
component: LoginSecondaryContentComponent,
outlet: "secondary",
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
...unauthUiRefreshSwap(
AnonLayoutWrapperComponent,
AnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "passwordHint",
},
titleId: "passwordHint",
},
children: [
{ path: "", component: HintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
state: "hint",
},
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
],
},
),
{
path: "",
component: AnonLayoutWrapperComponent,
@@ -381,6 +210,97 @@ const routes: Routes = [
},
],
},
{
path: "login",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: LoginComponent,
},
{
path: "",
component: LoginSecondaryContentComponent,
outlet: "secondary",
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
titleId: "loginInitiated",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
titleId: "adminApprovalRequested",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
state: "hint",
},
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "send/:sendId/:key",
data: {
@@ -432,64 +352,24 @@ const routes: Routes = [
},
],
},
...unauthUiRefreshSwap(
SsoComponentV1,
SsoComponent,
{
path: "sso",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "enterpriseSingleSignOn",
},
titleId: "enterpriseSingleSignOn",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: SsoComponentV1,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "sso",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "singleSignOn",
},
titleId: "enterpriseSingleSignOn",
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
titleAreaMaxWidth: "md",
pageIcon: SsoKeyIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: SsoComponent,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
{
path: "login",
path: "sso",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "singleSignOn",
},
titleId: "enterpriseSingleSignOn",
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
titleAreaMaxWidth: "md",
pageIcon: SsoKeyIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: LoginComponent,
component: SsoComponent,
},
{
path: "",
@@ -497,11 +377,6 @@ const routes: Routes = [
outlet: "environment-selector",
},
],
data: {
pageTitle: {
key: "logIn",
},
},
},
...unauthUiRefreshSwap(
TwoFactorComponentV1,

View File

@@ -26,77 +26,73 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<tr bitRow>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
{{ "timesExposed" | i18n }}
</th>
</tr>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
{{ "timesExposed" | i18n }}
</th>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "exposedXTimes" | i18n: (r.exposedXTimes | number) }}
</span>
</td>
</tr>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }}
</span>
</td>
</ng-template>
</bit-table>
</bit-table-scroll>
</ng-container>
</div>
<ng-template #cipherAddEdit></ng-template>

View File

@@ -33,73 +33,69 @@
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
<ng-container header *ngIf="!isAdminConsoleActive">
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
</tr>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }}
</span>
</td>
</tr>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
</span>
</td>
</ng-template>
</bit-table>
</bit-table-scroll>
</ng-container>
</div>
<ng-template #cipherAddEdit></ng-template>

View File

@@ -31,77 +31,73 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<tr bitRow>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="score" default>
{{ "weakness" | i18n }}
</th>
</tr>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="score" default>
{{ "weakness" | i18n }}
</th>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge [variant]="r.reportValue.badgeVariant">
{{ r.reportValue.label | i18n }}
</span>
</td>
</tr>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<span bitBadge [variant]="row.reportValue.badgeVariant">
{{ row.reportValue.label | i18n }}
</span>
</td>
</ng-template>
</bit-table>
</bit-table-scroll>
</ng-container>
</div>
<ng-template #cipherAddEdit></ng-template>

View File

@@ -281,7 +281,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
},
action: this.applyFolderFilter,
edit: {
text: "editFolder",
filterName: this.i18nService.t("folder"),
action: this.editFolder,
},
};

View File

@@ -89,7 +89,7 @@
*ngIf="editInfo && f.node.id"
class="edit-button"
(click)="onEdit(f)"
appA11yTitle="{{ editInfo.text | i18n }}"
appA11yTitle="{{ 'editWithName' | i18n: editInfo.filterName : f.node.name }}"
>
<i class="bwi bwi-pencil bwi-fw" aria-hidden="true"></i>
</button>

View File

@@ -31,7 +31,7 @@ export type VaultFilterSection = {
};
action: (filterNode: TreeNode<VaultFilterType>) => Promise<void>;
edit?: {
text: string;
filterName: string;
action: (filter: VaultFilterType) => void;
};
add?: {

View File

@@ -9,14 +9,28 @@ import {
OnInit,
Output,
} from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { Unassigned, CollectionView } from "@bitwarden/admin-console/common";
import {
Unassigned,
CollectionView,
CollectionAdminService,
} from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { BreadcrumbsModule, MenuModule } from "@bitwarden/components";
import {
BreadcrumbsModule,
DialogService,
MenuModule,
SimpleDialogOptions,
} from "@bitwarden/components";
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -81,7 +95,13 @@ export class VaultHeaderComponent implements OnInit {
/** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>();
constructor(private i18nService: I18nService) {}
constructor(
private i18nService: I18nService,
private collectionAdminService: CollectionAdminService,
private dialogService: DialogService,
private router: Router,
private configService: ConfigService,
) {}
async ngOnInit() {}
@@ -199,6 +219,56 @@ export class VaultHeaderComponent implements OnInit {
}
async addCollection(): Promise<void> {
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
);
if (
this.organizations.length == 1 &&
organization.productTierType === ProductTierType.Free &&
isBreadcrumbEventLogsEnabled
) {
const collections = await this.collectionAdminService.getAll(organization.id);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
}
this.onAddCollection.emit();
}
private async showFreeOrgUpgradeDialog(organization: Organization): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
organization.canEditSubscription
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
organization.maxCollections,
),
type: "primary",
};
if (organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
const result: boolean | undefined = await firstValueFrom(simpleDialog.closed);
if (!result) {
return;
}
if (organization.canEditSubscription) {
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
}
}

View File

@@ -449,6 +449,9 @@
"dragToSort": {
"message": "Drag to sort"
},
"dragToReorder": {
"message": "Drag to reorder"
},
"cfTypeText": {
"message": "Text"
},
@@ -491,6 +494,19 @@
"editFolder": {
"message": "Edit folder"
},
"editWithName": {
"message": "Edit $ITEM$: $NAME$",
"placeholders": {
"item": {
"content": "$1",
"example": "login"
},
"name": {
"content": "$2",
"example": "Social"
}
}
},
"newFolder": {
"message": "New folder"
},
@@ -4551,6 +4567,40 @@
}
}
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
},
"reorderFieldDown": {
"message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
},
"keyUpdateFoldersFailed": {
"message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed."
},
@@ -9362,6 +9412,15 @@
"deviceManagementDesc": {
"message": "Configure device management for Bitwarden using the implementation guide for your platform."
},
"deviceIdMissing": {
"message": "Device ID is missing"
},
"deviceTypeMissing": {
"message": "Device type is missing"
},
"deviceCreationDateMissing": {
"message": "Device creation date is missing"
},
"desktopRequired": {
"message": "Desktop required"
},
@@ -10558,5 +10617,8 @@
},
"upgradeEventLogMessage":{
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
}
}

View File

@@ -1,7 +1,7 @@
<app-header>
<ng-container slot="title-suffix" *ngIf="loading || actionInProgress">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -24,7 +24,7 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
@@ -74,7 +74,7 @@
{{ orgDomain.lastCheckedDate | date: "medium" }}
</td>
<td bitCell class="table-list-options tw-text-right">
<td bitCell class="tw-text-right">
<button
[bitMenuTriggerFor]="orgDomainOptions"
class="tw-border-none tw-bg-transparent tw-text-main"

View File

@@ -38,8 +38,6 @@
</bit-hint>
</bit-form-field>
<hr />
<bit-radio-group formControlName="memberDecryptionType">
<bit-label>{{ "memberDecryptionOption" | i18n }}</bit-label>
@@ -156,7 +154,7 @@
</bit-form-field>
</ng-container>
<hr />
<hr class="tw-mb-4" />
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>

View File

@@ -39,11 +39,10 @@ export class Collection extends Domain {
}
decrypt(orgKey: OrgKey): Promise<CollectionView> {
return this.decryptObj(
return this.decryptObj<Collection, CollectionView>(
this,
new CollectionView(this),
{
name: null,
},
["name"],
this.organizationId,
orgKey,
);

View File

@@ -0,0 +1,9 @@
import { Observable } from "rxjs";
export abstract class DeviceTrustToastService {
/**
* An observable pipeline that observes any cross-application toast messages
* that need to be shown as part of the trusted device encryption (TDE) process.
*/
abstract setupListeners$: Observable<void>;
}

View File

@@ -0,0 +1,44 @@
import { merge, Observable, tap } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
export class DeviceTrustToastService implements DeviceTrustToastServiceAbstraction {
private adminLoginApproved$: Observable<void>;
private deviceTrusted$: Observable<void>;
setupListeners$: Observable<void>;
constructor(
private authRequestService: AuthRequestServiceAbstraction,
private deviceTrustService: DeviceTrustServiceAbstraction,
private i18nService: I18nService,
private toastService: ToastService,
) {
this.adminLoginApproved$ = this.authRequestService.adminLoginApproved$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("loginApproved"),
});
}),
);
this.deviceTrusted$ = this.deviceTrustService.deviceTrusted$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("deviceTrusted"),
});
}),
);
this.setupListeners$ = merge(this.adminLoginApproved$, this.deviceTrusted$);
}
}

View File

@@ -0,0 +1,167 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EMPTY, of } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "./device-trust-toast.service.implementation";
describe("DeviceTrustToastService", () => {
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let sut: DeviceTrustToastServiceAbstraction;
beforeEach(() => {
authRequestService = mock<AuthRequestServiceAbstraction>();
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
i18nService.t.mockImplementation((key: string) => key); // just return the key that was given
});
const initService = () => {
return new DeviceTrustToastService(
authRequestService,
deviceTrustService,
i18nService,
toastService,
);
};
const loginApprovalToastOptions = {
variant: "success",
title: "",
message: "loginApproved",
};
const deviceTrustedToastOptions = {
variant: "success",
title: "",
message: "deviceTrusted",
};
describe("setupListeners$", () => {
describe("given adminLoginApproved$ emits and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ emits and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
});
});

View File

@@ -317,6 +317,8 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -1463,6 +1465,16 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
}),
safeProvider({
provide: DeviceTrustToastServiceAbstraction,
useClass: DeviceTrustToastService,
deps: [
AuthRequestServiceAbstraction,
DeviceTrustServiceAbstraction,
I18nServiceAbstraction,
ToastService,
],
}),
];
@NgModule({

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
@@ -10,7 +10,7 @@ import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class CollectionFilterComponent {
export class CollectionFilterComponent implements OnInit {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() collectionNodes: DynamicTreeNode<CollectionView>;
@@ -51,4 +51,13 @@ export class CollectionFilterComponent {
async toggleCollapse(node: ITreeNodeObject) {
this.onNodeCollapseStateChange.emit(node);
}
ngOnInit() {
// Populate the set with all node IDs so all nodes are collapsed initially.
if (this.collectionNodes?.fullList) {
this.collectionNodes.fullList.forEach((node) => {
this.collapsedFilterNodes.add(node.id);
});
}
}
}

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs";
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -19,11 +19,9 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -121,7 +119,6 @@ export class LoginComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private logService: LogService,
private validationService: ValidationService,
private configService: ConfigService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {
this.clientType = this.platformUtilsService.getClientType();
@@ -131,9 +128,6 @@ export class LoginComponent implements OnInit, OnDestroy {
// Add popstate listener to listen for browser back button clicks
window.addEventListener("popstate", this.handlePopState);
// TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed.
this.listenForUnauthUiRefreshFlagChanges();
await this.defaultOnInit();
if (this.clientType === ClientType.Desktop) {
@@ -154,30 +148,6 @@ export class LoginComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private listenForUnauthUiRefreshFlagChanges() {
this.configService
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
// If the flag is turned OFF, we must force a reload to ensure the correct UI is shown
if (!flag) {
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
const uniqueQueryParams = {
...qParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter
};
await this.router.navigate(["/"], {
queryParams: uniqueQueryParams,
});
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
submit = async (): Promise<void> => {
if (this.clientType === ClientType.Desktop) {
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {

View File

@@ -231,20 +231,6 @@ describe("TwoFactorAuthComponent", () => {
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
// Act
await component.submit("testToken");
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["update-temp-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
};
describe("Standard 2FA scenarios", () => {
describe("submit", () => {
const token = "testToken";
@@ -316,26 +302,6 @@ describe("TwoFactorAuthComponent", () => {
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
it("navigates to the component's defined success route (vault is default) when the login is successful", async () => {
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
@@ -412,29 +378,7 @@ describe("TwoFactorAuthComponent", () => {
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
describe("Given Trusted Device Encryption is enabled and user doesn't need to set a MP", () => {
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
@@ -442,7 +386,6 @@ describe("TwoFactorAuthComponent", () => {
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});

View File

@@ -505,11 +505,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
);
}
// note: this flow affects both TDE & standard users
if (this.isForcePasswordResetRequired(authResult)) {
return await this.handleForcePasswordReset(this.orgSsoIdentifier);
}
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
@@ -524,6 +519,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
const requireSetPassword =
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
// New users without a master password must set a master password before advancing.
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(this.orgSsoIdentifier);
@@ -633,14 +629,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
return forceResetReasons.includes(authResult.forcePasswordReset);
}
private async handleForcePasswordReset(orgIdentifier: string | undefined) {
await this.router.navigate(["update-temp-password"], {
queryParams: {
identifier: orgIdentifier,
},
});
}
showContinueButton() {
return (
this.selectedProviderType != null &&

View File

@@ -12,6 +12,12 @@ export abstract class AuthRequestServiceAbstraction {
/** Emits an auth request id when an auth request has been approved. */
authRequestPushNotification$: Observable<string>;
/**
* Emits when a login has been approved by an admin. This emission is specifically for the
* purpose of notifying the consuming component to display a toast informing the user.
*/
adminLoginApproved$: Observable<void>;
/**
* Returns an admin auth request for the given user if it exists.
* @param userId The user id.
@@ -106,4 +112,13 @@ export abstract class AuthRequestServiceAbstraction {
* @returns The dash-delimited fingerprint phrase.
*/
abstract getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string>;
/**
* Passes a value to the adminLoginApprovedSubject via next(), which causes the
* adminLoginApproved$ observable to emit.
*
* The purpose is to notify consuming components (of adminLoginApproved$) to display
* a toast informing the user that a login has been approved by an admin.
*/
abstract emitAdminLoginApproved(): void;
}

View File

@@ -306,6 +306,31 @@ describe("LoginStrategy", () => {
expect(result).toEqual(expected);
});
it("processes a forcePasswordReset response properly", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.userId = userId;
expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
expected.resetMasterPassword = false;
expected.twoFactorProviders = {} as Partial<
Record<TwoFactorProviderType, Record<string, string>>
>;
expected.captchaSiteKey = "";
expected.twoFactorProviders = null;
expect(result).toEqual(expected);
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
);
});
it("rejects login if CAPTCHA is required", async () => {
// Sample CAPTCHA response
const tokenResponse = new IdentityCaptchaResponse({

View File

@@ -271,17 +271,24 @@ export abstract class LoginStrategy {
}
}
result.resetMasterPassword = response.resetMasterPassword;
// Convert boolean to enum
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
}
// Must come before setting keys, user key needs email to update additional keys
// Must come before setting keys, user key needs email to update additional keys.
const userId = await this.saveAccountInformation(response);
result.userId = userId;
result.resetMasterPassword = response.resetMasterPassword;
// Convert boolean to enum and set the state for the master password service to
// so we know when we reach the auth guard that we need to guide them properly to admin
// password reset.
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
);
}
if (response.twoFactorToken != null) {
// note: we can read email from access token b/c it was saved in saveAccountInformation
const userEmail = await this.tokenService.getEmail();
@@ -300,7 +307,9 @@ export abstract class LoginStrategy {
// The keys comes from different sources depending on the login strategy
protected abstract setMasterKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to

View File

@@ -6,7 +6,6 @@ import { Jsonify } from "type-fest";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
@@ -108,14 +107,6 @@ export class SsoLoginStrategy extends LoginStrategy {
const email = ssoAuthResult.email;
const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
// Auth guard currently handles redirects for this.
if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.masterPasswordService.setForceSetPasswordReason(
ssoAuthResult.forcePasswordReset,
ssoAuthResult.userId,
);
}
this.cache.next({
...this.cache.value,
email,
@@ -278,7 +269,8 @@ export class SsoLoginStrategy extends LoginStrategy {
// TODO: eventually we post and clean up DB as well once consumed on client
await this.authRequestService.clearAdminAuthRequest(userId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
// This notification will be picked up by the SsoComponent to handle displaying a toast to the user
this.authRequestService.emitAdminLoginApproved();
}
}
}

View File

@@ -43,6 +43,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private authRequestPushNotificationSubject = new Subject<string>();
authRequestPushNotification$: Observable<string>;
// Observable emission is used to trigger a toast in consuming components
private adminLoginApprovedSubject = new Subject<void>();
adminLoginApproved$: Observable<void>;
constructor(
private appIdService: AppIdService,
private accountService: AccountService,
@@ -53,6 +57,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private stateProvider: StateProvider,
) {
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
this.adminLoginApproved$ = this.adminLoginApprovedSubject.asObservable();
}
async getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null> {
@@ -207,4 +212,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
async getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
return (await this.keyService.getFingerprint(email.toLowerCase(), publicKey)).join("-");
}
emitAdminLoginApproved(): void {
this.adminLoginApprovedSubject.next();
}
}

View File

@@ -16,6 +16,12 @@ export abstract class DeviceTrustServiceAbstraction {
*/
supportsDeviceTrust$: Observable<boolean>;
/**
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
* the consuming component to display a toast informing the user the device has been trusted.
*/
deviceTrusted$: Observable<void>;
/**
* @description Checks if the device trust feature is supported for the given user.
*/

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
// Observable emission is used to trigger a toast in consuming components
private deviceTrustedSubject = new Subject<void>();
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
// This emission will be picked up by consuming components to handle displaying a toast to the user
this.deviceTrustedSubject.next();
return deviceResponse;
}

View File

@@ -26,6 +26,7 @@ export enum FeatureFlag {
ItemShare = "item-share",
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
DesktopSendUIRefresh = "desktop-send-ui-refresh",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
VaultBulkManagementAction = "vault-bulk-management-action",
@@ -82,6 +83,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,

View File

@@ -36,7 +36,6 @@ export abstract class EncryptService {
): Promise<Uint8Array | null>;
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;
/**
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
* @param items The items to decrypt

View File

@@ -78,8 +78,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No key provided for decryption.");
}
key = this.resolveLegacyKey(key, encString);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encString?.mac == null) {
this.logService.error(
@@ -145,8 +143,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("Nothing provided for decryption.");
}
key = this.resolveLegacyKey(key, encThing);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encThing.macBytes == null) {
this.logService.error(
@@ -298,19 +294,4 @@ export class EncryptServiceImplementation implements EncryptService {
this.logService.error(msg);
}
}
/**
* Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged
* @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt
*/
resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey {
if (
encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 &&
key.encType === EncryptionType.AesCbc256_B64
) {
return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
}
return key;
}
}

View File

@@ -325,6 +325,25 @@ describe("EncryptService", () => {
});
});
describe("decryptToUtf8", () => {
it("throws if no key is provided", () => {
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
"No key provided for decryption.",
);
});
it("returns null if key is mac key but encstring has no mac", async () => {
const key = new SymmetricCryptoKey(
makeStaticByteArray(64, 0),
EncryptionType.AesCbc256_HmacSha256_B64,
);
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
const actual = await encryptService.decryptToUtf8(encString, key);
expect(actual).toBeNull();
expect(logService.error).toHaveBeenCalled();
});
});
describe("rsa", () => {
const data = makeStaticByteArray(10, 100);
const encryptedData = makeStaticByteArray(10, 150);
@@ -370,17 +389,16 @@ describe("EncryptService", () => {
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
});
it.each([
EncryptionType.AesCbc256_B64,
EncryptionType.AesCbc128_HmacSha256_B64,
EncryptionType.AesCbc256_HmacSha256_B64,
])("throws if encryption type is %s", async (encType) => {
encString.encryptionType = encType;
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
"throws if encryption type is %s",
async (encType) => {
encString.encryptionType = encType;
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
});
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
},
);
it("decrypts data with provided key", async () => {
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
@@ -398,30 +416,6 @@ describe("EncryptService", () => {
});
});
describe("resolveLegacyKey", () => {
it("creates a legacy key if required", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
const encString = mock<EncString>();
encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64;
const actual = encryptService.resolveLegacyKey(key, encString);
const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(actual).toEqual(expected);
});
it("does not create a legacy key if not required", async () => {
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType);
const encString = mock<EncString>();
encString.encryptionType = encType;
const actual = encryptService.resolveLegacyKey(key, encString);
expect(actual).toEqual(key);
});
});
describe("hash", () => {
it("hashes a string and returns b64", async () => {
cryptoFunctionService.hash.mockResolvedValue(Uint8Array.from([1, 2, 3]));

View File

@@ -1,6 +1,6 @@
export enum EncryptionType {
AesCbc256_B64 = 0,
AesCbc128_HmacSha256_B64 = 1,
// Type 1 was the unused and removed AesCbc128_HmacSha256_B64
AesCbc256_HmacSha256_B64 = 2,
Rsa2048_OaepSha256_B64 = 3,
Rsa2048_OaepSha1_B64 = 4,
@@ -17,12 +17,10 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
}
/** The expected number of parts to a serialized EncString of the given encryption type.
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
* AesCbc128_HmacSha256_B64 will have 3 parts.
* For example, an EncString of type AesCbc256_B64 will have 2 parts
*
* Example of annotated serialized EncStrings:
* 0.iv|data
* 1.iv|data|mac
* 2.iv|data|mac
* 3.data
* 4.data
@@ -33,7 +31,6 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
*/
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
[EncryptionType.AesCbc256_B64]: 2,
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
@@ -15,6 +13,19 @@ export type DecryptedObject<
TDecryptedKeys extends EncStringKeys<TEncryptedObject>,
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
// extracts shared keys from the domain and view types
type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
ConditionalKeys<D, EncString | null>) &
(keyof V & ConditionalKeys<V, string | null>);
type DomainEncryptableKeys<D extends Domain> = {
[key in ConditionalKeys<D, EncString | null>]: EncString | null;
};
type ViewEncryptableKeys<V extends View> = {
[key in ConditionalKeys<V, string | null>]: string | null;
};
// https://contributing.bitwarden.com/architecture/clients/data-model#domain
export default class Domain {
protected buildDomainModel<D extends Domain>(
@@ -37,6 +48,7 @@ export default class Domain {
}
}
}
protected buildDataModel<D extends Domain>(
domain: D,
dataObj: any,
@@ -58,31 +70,24 @@ export default class Domain {
}
}
protected async decryptObj<T extends View>(
viewModel: T,
map: any,
orgId: string,
key: SymmetricCryptoKey = null,
protected async decryptObj<D extends Domain, V extends View>(
domain: DomainEncryptableKeys<D>,
viewModel: ViewEncryptableKeys<V>,
props: EncryptableKeys<D, V>[],
orgId: string | null,
key: SymmetricCryptoKey | null = null,
objectContext: string = "No Domain Context",
): Promise<T> {
const self: any = this;
for (const prop in map) {
// eslint-disable-next-line
if (!map.hasOwnProperty(prop)) {
continue;
}
const mapProp = map[prop] || prop;
if (self[mapProp]) {
(viewModel as any)[prop] = await self[mapProp].decrypt(
): Promise<V> {
for (const prop of props) {
viewModel[prop] =
(await domain[prop]?.decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
)) ?? null;
}
return viewModel;
return viewModel as V;
}
/**
@@ -111,7 +116,7 @@ export default class Domain {
const decryptedObjects = [];
for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
const value = this[prop] as EncString;
const decrypted = await this.decryptProperty(
prop,
value,
@@ -138,11 +143,9 @@ export default class Domain {
encryptService: EncryptService,
decryptTrace: string,
) {
let decrypted: string = null;
let decrypted: string | null = null;
if (value) {
decrypted = await value.decryptWithKey(key, encryptService, decryptTrace);
} else {
decrypted = null;
}
return {
[propertyKey]: decrypted,

View File

@@ -5,28 +5,28 @@ import { EncArrayBuffer } from "./enc-array-buffer";
describe("encArrayBuffer", () => {
describe("parses the buffer", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"],
])("with %c%s", (encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
test.each([[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"]])(
"with %c%s",
(encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const actual = new EncArrayBuffer(array);
const actual = new EncArrayBuffer(array);
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
});
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
},
);
it("with AesCbc256_B64", () => {
const encType = EncryptionType.AesCbc256_B64;
@@ -50,7 +50,6 @@ describe("encArrayBuffer", () => {
describe("throws if the buffer has an invalid length", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"],
[EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"],
])("with %c%c%s", (encType: EncryptionType, minLength: number) => {

View File

@@ -20,7 +20,6 @@ export class EncArrayBuffer implements Encrypted {
const encType = encBytes[0];
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:
case EncryptionType.AesCbc256_HmacSha256_B64: {
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH;
if (encBytes.length < minimumLength) {

View File

@@ -60,9 +60,7 @@ describe("EncString", () => {
const cases = [
"aXY=|Y3Q=", // AesCbc256_B64 w/out header
"aXY=|Y3Q=|cnNhQ3Q=", // AesCbc128_HmacSha256_B64 w/out header
"0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_B64 with header
"1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc128_HmacSha256_B64
"2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_HmacSha256_B64
"3.QmFzZTY0UGFydA==", // Rsa2048_OaepSha256_B64
"4.QmFzZTY0UGFydA==", // Rsa2048_OaepSha1_B64

View File

@@ -89,7 +89,6 @@ export class EncString implements Encrypted {
}
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:
case EncryptionType.AesCbc256_HmacSha256_B64:
this.iv = encPieces[0];
this.data = encPieces[1];
@@ -132,10 +131,7 @@ export class EncString implements Encrypted {
}
} else {
encPieces = encryptedString.split("|");
encType =
encPieces.length === 3
? EncryptionType.AesCbc128_HmacSha256_B64
: EncryptionType.AesCbc256_B64;
encType = EncryptionType.AesCbc256_B64;
}
return {
@@ -160,7 +156,7 @@ export class EncString implements Encrypted {
async decrypt(
orgId: string | null,
key: SymmetricCryptoKey = null,
key: SymmetricCryptoKey | null = null,
context?: string,
): Promise<string> {
if (this.decryptedValue != null) {

View File

@@ -27,21 +27,6 @@ describe("SymmetricCryptoKey", () => {
});
});
it("AesCbc128_HmacSha256_B64", () => {
const key = makeStaticByteArray(32);
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 16),
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
encType: 1,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: key.slice(16, 32),
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
});
});
it("AesCbc256_HmacSha256_B64", () => {
const key = makeStaticByteArray(64);
const cryptoKey = new SymmetricCryptoKey(key);

View File

@@ -38,9 +38,6 @@ export class SymmetricCryptoKey {
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
this.encKey = key;
this.macKey = null;
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
this.encKey = key.slice(0, 16);
this.macKey = key.slice(16, 32);
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
this.encKey = key.slice(0, 32);
this.macKey = key.slice(32, 64);

View File

@@ -54,14 +54,7 @@ export class SendAccess extends Domain {
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
const model = new SendAccessView(this);
await this.decryptObj(
model,
{
name: null,
},
null,
key,
);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
switch (this.type) {
case SendType.File:

View File

@@ -34,15 +34,13 @@ export class SendFile extends Domain {
}
async decrypt(key: SymmetricCryptoKey): Promise<SendFileView> {
const view = await this.decryptObj(
return await this.decryptObj<SendFile, SendFileView>(
this,
new SendFileView(this),
{
fileName: null,
},
["fileName"],
null,
key,
);
return view;
}
static fromJSON(obj: Jsonify<SendFile>) {

View File

@@ -30,11 +30,10 @@ export class SendText extends Domain {
}
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
return this.decryptObj(
return this.decryptObj<SendText, SendTextView>(
this,
new SendTextView(this),
{
text: null,
},
["text"],
null,
key,
);

View File

@@ -87,15 +87,7 @@ export class Send extends Domain {
// TODO: error?
}
await this.decryptObj(
model,
{
name: null,
notes: null,
},
null,
model.cryptoKey,
);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
switch (this.type) {
case SendType.File:

View File

@@ -43,11 +43,10 @@ export class Attachment extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
new AttachmentView(this),
{
fileName: null,
},
["fileName"],
orgId,
encKey,
"DomainType: Attachment; " + context,

View File

@@ -42,16 +42,10 @@ export class Card extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
return this.decryptObj(
return this.decryptObj<Card, CardView>(
this,
new CardView(),
{
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
},
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
orgId,
encKey,
"DomainType: Card; " + context,

View File

@@ -154,12 +154,10 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
bypassValidation = false;
}
await this.decryptObj(
await this.decryptObj<Cipher, CipherView>(
this,
model,
{
name: null,
notes: null,
},
["name", "notes"],
this.organizationId,
encKey,
);

View File

@@ -52,41 +52,38 @@ export class Fido2Credential extends Domain {
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
new Fido2CredentialView(),
{
credentialId: null,
keyType: null,
keyAlgorithm: null,
keyCurve: null,
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
discoverable: null,
},
[
"credentialId",
"keyType",
"keyAlgorithm",
"keyCurve",
"keyValue",
"rpId",
"userHandle",
"userName",
"rpName",
"userDisplayName",
],
orgId,
encKey,
);
const { counter } = await this.decryptObj(
{ counter: "" },
const { counter } = await this.decryptObj<
Fido2Credential,
{
counter: null,
},
orgId,
encKey,
);
counter: string;
}
>(this, { counter: "" }, ["counter"], orgId, encKey);
// Counter will end up as NaN if this fails
view.counter = parseInt(counter);
const { discoverable } = await this.decryptObj(
const { discoverable } = await this.decryptObj<Fido2Credential, { discoverable: string }>(
this,
{ discoverable: "" },
{
discoverable: null,
},
["discoverable"],
orgId,
encKey,
);

View File

@@ -35,12 +35,10 @@ export class Field extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj(
return this.decryptObj<Field, FieldView>(
this,
new FieldView(this),
{
name: null,
value: null,
},
["name", "value"],
orgId,
encKey,
);

View File

@@ -40,13 +40,7 @@ export class Folder extends Domain {
}
decrypt(): Promise<FolderView> {
return this.decryptObj(
new FolderView(this),
{
name: null,
},
null,
);
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
}
async decryptWithKey(

View File

@@ -66,28 +66,29 @@ export class Identity extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj(
return this.decryptObj<Identity, IdentityView>(
this,
new IdentityView(),
{
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
},
[
"title",
"firstName",
"middleName",
"lastName",
"address1",
"address2",
"address3",
"city",
"state",
"postalCode",
"country",
"company",
"email",
"phone",
"ssn",
"username",
"passportNumber",
"licenseNumber",
],
orgId,
encKey,
"DomainType: Identity; " + context,

View File

@@ -38,11 +38,10 @@ export class LoginUri extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj(
return this.decryptObj<LoginUri, LoginUriView>(
this,
new LoginUriView(this),
{
uri: null,
},
["uri"],
orgId,
encKey,
context,

View File

@@ -58,13 +58,10 @@ export class Login extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Login, LoginView>(
this,
new LoginView(this),
{
username: null,
password: null,
totp: null,
},
["username", "password", "totp"],
orgId,
encKey,
`DomainType: Login; ${context}`,

View File

@@ -25,11 +25,10 @@ export class Password extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj(
return this.decryptObj<Password, PasswordHistoryView>(
this,
new PasswordHistoryView(this),
{
password: null,
},
["password"],
orgId,
encKey,
"DomainType: PasswordHistory",

View File

@@ -36,13 +36,10 @@ export class SshKey extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
return this.decryptObj(
return this.decryptObj<SshKey, SshKeyView>(
this,
new SshKeyView(),
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
["privateKey", "publicKey", "keyFingerprint"],
orgId,
encKey,
"DomainType: SshKey; " + context,

View File

@@ -12,7 +12,7 @@
</tr>
</thead>
<tbody>
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy" bitRow>
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy; templateCacheSize: 0" bitRow>
<ng-container *ngTemplateOutlet="rowDef.template; context: { $implicit: r }"></ng-container>
</tr>
</tbody>

View File

@@ -34,7 +34,7 @@ export class DefaultSendFormConfigService implements SendFormConfigService {
return {
mode,
sendType: sendType,
sendType: send?.type ?? sendType ?? SendType.Text,
areSendsAllowed,
originalSend: send,
};

View File

@@ -5,12 +5,15 @@
</h2>
</bit-section-header>
<bit-card>
<bit-card cdkDropList (cdkDropListDropped)="onUriItemDrop($event)">
<ng-container formArrayName="uris">
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
cdkDrag
[formControlName]="i"
(remove)="removeUri(i)"
(onKeydown)="onUriItemKeydown($event, i)"
[canReorder]="uriControls.length > 1"
[canRemove]="uriControls.length > 1"
[defaultMatchDetection]="defaultMatchDetection$ | async"
[index]="i"

View File

@@ -1,4 +1,5 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
@@ -16,6 +17,14 @@ import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "./autofill-options.component";
jest.mock("@angular/cdk/drag-drop", () => {
const actual = jest.requireActual("@angular/cdk/drag-drop");
return {
...actual,
moveItemInArray: jest.fn(actual.moveItemInArray),
};
});
describe("AutofillOptionsComponent", () => {
let component: AutofillOptionsComponent;
let fixture: ComponentFixture<AutofillOptionsComponent>;
@@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => {
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
});
describe("Drag & Drop Functionality", () => {
beforeEach(() => {
// Prevent autoadding an empty URI by setting a nonnull initial value.
// This overrides the call to initNewCipher.
// Now clear any existing URIs (including the autoadded one)
component.autofillOptionsForm.controls.uris.clear();
// Add exactly three URIs that we want to test reordering on.
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
});
it("should reorder URI inputs on drop event", () => {
// Simulate a drop event that moves the first URI (index 0) to the last position (index 2).
const dropEvent: CdkDragDrop<HTMLDivElement> = {
previousIndex: 0,
currentIndex: 2,
container: null,
previousContainer: null,
isPointerOverContainer: true,
item: null,
distance: { x: 0, y: 0 },
} as any;
component.onUriItemDrop(dropEvent);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
0,
2,
);
});
it("should reorder URI input via keyboard ArrowUp", async () => {
// Clear and add exactly two URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowUp on the second URI (index 1)
const keyEvent = {
key: "ArrowUp",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
// Force requestAnimationFrame to run synchronously
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
0,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldUp websiteUri 1 2",
"assertive",
);
});
it("should reorder URI input via keyboard ArrowDown", async () => {
// Clear and add exactly three URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowDown on the second URI (index 1)
const keyEvent = {
key: "ArrowDown",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
2,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldDown websiteUri 3 3",
"assertive",
);
});
});
});

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -41,6 +42,7 @@ interface UriField {
templateUrl: "./autofill-options.component.html",
standalone: true,
imports: [
DragDropModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
@@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit {
removeUri(i: number) {
this.autofillOptionsForm.controls.uris.removeAt(i);
}
/** Create a new list of LoginUriViews from the form objects and update the cipher */
private updateUriFields() {
this.cipherFormContainer.patchCipher((cipher) => {
cipher.login.uris = this.uriControls.map(
(control) =>
Object.assign(new LoginUriView(), {
uri: control.value.uri,
matchDetection: control.value.matchDetection ?? null,
}) as LoginUriView,
);
return cipher;
});
}
/** Reorder the controls to match the new order after a "drop" event */
onUriItemDrop(event: CdkDragDrop<HTMLDivElement>) {
moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex);
this.updateUriFields();
}
/** Handles a uri item keyboard up or down event */
async onUriItemKeydown(event: KeyboardEvent, index: number) {
if (event.key === "ArrowUp" && index !== 0) {
await this.reorderUriItems(event, index, "Up");
}
if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) {
await this.reorderUriItems(event, index, "Down");
}
}
/** Reorders the uri items from a keyboard up or down event */
async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") {
const currentIndex = previousIndex + (direction === "Up" ? -1 : 1);
event.preventDefault();
await this.liveAnnouncer.announce(
this.i18nService.t(
`reorderField${direction}`,
this.i18nService.t("websiteUri"),
currentIndex + 1,
this.uriControls.length,
),
"assertive",
);
moveItemInArray(this.uriControls, previousIndex, currentIndex);
this.updateUriFields();
// Refocus the button after the reorder
// Angular re-renders the list when moving an item up which causes the focus to be lost
// Wait for the next tick to ensure the button is rendered before focusing
requestAnimationFrame(() => {
(event.target as HTMLButtonElement).focus();
});
}
}

View File

@@ -1,35 +1,50 @@
<ng-container [formGroup]="uriForm">
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
bitIconButton="bwi-cog"
bitSuffix
[appA11yTitle]="toggleTitle"
(click)="toggleMatchDetection()"
data-testid="toggle-match-detection-button"
></button>
<button
type="button"
bitIconButton="bwi-minus-circle"
buttonType="danger"
bitSuffix
[appA11yTitle]="'deleteWebsite' | i18n"
*ngIf="canRemove"
(click)="removeUri()"
data-testid="remove-uri-button"
></button>
</bit-form-field>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
></bit-option>
</bit-select>
</bit-form-field>
<div class="tw-mb-4 pt-1">
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
bitIconButton="bwi-cog"
bitSuffix
[appA11yTitle]="toggleTitle"
(click)="toggleMatchDetection()"
data-testid="toggle-match-detection-button"
></button>
<button
type="button"
bitIconButton="bwi-minus-circle"
buttonType="danger"
bitSuffix
[appA11yTitle]="'deleteWebsite' | i18n"
*ngIf="canRemove"
(click)="removeUri()"
data-testid="remove-uri-button"
></button>
</bit-form-field>
<div class="tw-flex tw-items-center tw-ml-1.5">
<button
type="button"
bitIconButton="bwi-hamburger"
class="!tw-py-0 !tw-px-1"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: uriLabel"
(keydown)="handleKeydown($event)"
data-testid="reorder-toggle-button"
*ngIf="canReorder"
></button>
</div>
</div>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DragDropModule } from "@angular/cdk/drag-drop";
import { NgForOf, NgIf } from "@angular/common";
import {
Component,
@@ -43,6 +44,7 @@ import {
},
],
imports: [
DragDropModule,
FormFieldModule,
ReactiveFormsModule,
IconButtonModule,
@@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor {
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
];
/**
* Whether the option can be reordered. If false, the reorder button will be hidden.
*/
@Input({ required: true })
canReorder: boolean;
/**
* Whether the URI can be removed from the form. If false, the remove button will be hidden.
*/
@@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor {
*/
@Input({ required: true }) index: number;
@Output()
onKeydown = new EventEmitter<KeyboardEvent>();
/**
* Emits when the remove button is clicked and URI should be removed from the form.
*/
@@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor {
private onChange: any = () => {};
private onTouched: any = () => {};
protected handleKeydown(event: KeyboardEvent) {
this.onKeydown.emit(event);
}
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,

View File

@@ -87,7 +87,7 @@
(click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })"
[appA11yTitle]="'editFieldLabel' | i18n: field.value.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
class="tw-self-center tw-mt-2"
data-testid="edit-custom-field-button"
*ngIf="!isPartialEdit"
></button>
@@ -95,7 +95,7 @@
<button
type="button"
bitIconButton="bwi-hamburger"
class="tw-self-end"
class="tw-self-center tw-mt-2"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
(keydown)="handleKeyDown($event, field.value.name, i)"