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

Merge branch 'main' into innovation/user-achievements/event-stream-prototype

This commit is contained in:
✨ Audrey ✨
2025-03-12 10:21:08 -04:00
171 changed files with 3271 additions and 2184 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": {
@@ -5128,6 +5143,33 @@
"extraWide": {
"message": "Extra wide"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
},
"importSshKey": {
"message": "Import"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
},
"enterSshKeyPassword": {
"message": "Enter password"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"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

@@ -21,8 +21,7 @@ describe("InlineMenuFieldQualificationService", () => {
});
describe("isFieldForLoginForm", () => {
it("does not disqualify totp fields for premium users with flag set to true", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
it("does not disqualify totp fields for premium users", () => {
inlineMenuFieldQualificationService["premiumEnabled"] = true;
const field = mock<AutofillField>({
type: "text",
@@ -37,24 +36,7 @@ describe("InlineMenuFieldQualificationService", () => {
);
});
it("disqualifies totp fields for premium users with flag set to false", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = false;
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
const field = mock<AutofillField>({
type: "text",
autoCompleteType: "one-time-code",
htmlName: "totp",
htmlID: "totp",
placeholder: "totp",
});
expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe(
false,
);
});
it("disqualifies totp fields for non-premium users with flag set to true", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
it("disqualifies totp fields for non-premium users", () => {
inlineMenuFieldQualificationService["premiumEnabled"] = false;
const field = mock<AutofillField>({
type: "text",

View File

@@ -151,17 +151,14 @@ export class InlineMenuFieldQualificationService
]);
private totpFieldAutocompleteValue = "one-time-code";
private inlineMenuFieldQualificationFlagSet = false;
private inlineMenuTotpFeatureFlag = false;
private premiumEnabled = false;
constructor() {
void Promise.all([
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
sendExtensionMessage("getInlineMenuTotpFeatureFlag"),
sendExtensionMessage("getUserPremiumStatus"),
]).then(([fieldQualificationFlag, totpFeatureFlag, premiumStatus]) => {
]).then(([fieldQualificationFlag, premiumStatus]) => {
this.inlineMenuFieldQualificationFlagSet = !!fieldQualificationFlag?.result;
this.inlineMenuTotpFeatureFlag = !!totpFeatureFlag?.result;
this.premiumEnabled = !!premiumStatus?.result;
});
}
@@ -180,7 +177,7 @@ export class InlineMenuFieldQualificationService
/**
* Totp inline menu is available only for premium users.
*/
if (this.inlineMenuTotpFeatureFlag && this.premiumEnabled) {
if (this.premiumEnabled) {
const isTotpField = this.isTotpField(field);
// Autofill does not fill totp inputs with a "password" `type` attribute value
const passwordType = field.type === "password";

View File

@@ -1012,6 +1012,7 @@ export default class MainBackground {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
);
this.individualVaultExportService = new IndividualVaultExportService(

View File

@@ -78,7 +78,6 @@ export default class RuntimeBackground {
BiometricsCommands.GetBiometricsStatusForUser,
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
"getInlineMenuFieldQualificationFeatureFlag",
"getInlineMenuTotpFeatureFlag",
"getUserPremiumStatus",
];
@@ -217,9 +216,6 @@ export default class RuntimeBackground {
);
return result;
}
case "getInlineMenuTotpFeatureFlag": {
return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuTotp);
}
}
}

View File

@@ -64,8 +64,16 @@ export class PopupViewCacheService implements ViewCacheService {
filter((e) => e instanceof NavigationEnd),
/** Skip the first navigation triggered by `popupRouterCacheGuard` */
skip(1),
filter((e: NavigationEnd) =>
// viewing/editing a cipher and navigating back to the vault list should not clear the cache
["/view-cipher", "/edit-cipher", "/tabs/vault"].every(
(route) => !e.urlAfterRedirects.startsWith(route),
),
),
)
.subscribe(() => this.clearState());
.subscribe((e) => {
return this.clearState();
});
}
/**

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

@@ -130,7 +130,11 @@ import {
KeyService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultSshImportPromptService,
PasswordRepromptService,
SshImportPromptService,
} from "@bitwarden/vault";
import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service";
import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
@@ -653,6 +657,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction, Router],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -6,6 +6,7 @@
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -27,12 +27,7 @@
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SshKey)"
*ngIf="sshKeysEnabled"
>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</a>

View File

@@ -5,8 +5,6 @@ import { Component, Input, OnInit } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -40,13 +38,9 @@ export class NewItemDropdownV2Component implements OnInit {
constructor(
private router: Router,
private dialogService: DialogService,
private configService: ConfigService,
) {}
sshKeysEnabled = false;
async ngOnInit() {
this.sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
this.tab = await BrowserApi.getTabFromCurrentWindow();
}

View File

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

View File

@@ -70,7 +70,12 @@
</ng-template>
<ng-template #descriptionText>
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
<div
*ngIf="description"
class="tw-text-muted tw-px-1 tw-mb-2"
[ngClass]="{ '!tw-mb-0': disableDescriptionMargin }"
bitTypography="body2"
>
{{ description }}
</div>
</ng-template>

View File

@@ -245,6 +245,12 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
@Input({ transform: booleanAttribute })
disableSectionMargin: boolean = false;
/**
* Remove the description margin
*/
@Input({ transform: booleanAttribute })
disableDescriptionMargin: boolean = false;
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
* @param cipher

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

@@ -780,6 +780,7 @@ export class ServiceContainer {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
);
this.individualExportService = new IndividualVaultExportService(

View File

@@ -1,402 +0,0 @@
use ed25519;
use pkcs8::{
der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument,
};
use ssh_key::{
private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair},
HashAlg, LineEnding,
};
const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----";
const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----";
const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier =
ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
#[derive(Debug)]
enum KeyType {
Ed25519,
Rsa,
Unknown,
}
pub fn import_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
match encoded_key.lines().next() {
Some(PKCS1_HEADER) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
}),
Some(PKCS8_UNENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, None) {
Ok(result) => Ok(result),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
},
Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) {
Ok(result) => Ok(result),
Err(err) => match err {
SshKeyImportError::PasswordRequired => Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
}),
SshKeyImportError::WrongPassword => Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
}),
SshKeyImportError::ParsingError => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
},
},
Some(OPENSSH_HEADER) => import_openssh_key(encoded_key, password),
Some(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
None => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyImportResult, SshKeyImportError> {
let der = match SecretDocument::from_pem(&encoded_key) {
Ok((_, doc)) => doc,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
let decrypted_der = match password.clone() {
Some(password) => {
let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes())
{
Ok(info) => info,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
match encrypted_private_key_info.decrypt(password.as_bytes()) {
Ok(der) => der,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
}
None => der,
};
let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes())
.map_err(|_| SshKeyImportError::ParsingError)?
.algorithm
.oid
{
ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519,
RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa,
_ => KeyType::Unknown,
};
match key_type {
KeyType::Ed25519 => {
let pk: ed25519::KeypairBytes = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
ed25519::pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let pk: Ed25519Keypair =
Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key));
let private_key = ssh_key::private::PrivateKey::from(pk);
Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
})
}
KeyType::Rsa => {
let pk: rsa::RsaPrivateKey = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let rsa_keypair: Result<RsaKeypair, ssh_key::Error> = RsaKeypair::try_from(pk);
match rsa_keypair {
Ok(rsa_keypair) => {
let private_key = ssh_key::private::PrivateKey::from(rsa_keypair);
Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key
.to_openssh(LineEnding::LF)
.unwrap()
.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
})
}
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
_ => Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
}),
}
}
fn import_openssh_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key);
let private_key = match private_key {
Ok(k) => k,
Err(err) => {
match err {
ssh_key::Error::AlgorithmUnknown
| ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
});
}
_ => {}
}
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
if private_key.is_encrypted() && password.is_empty() {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
});
}
let private_key = if private_key.is_encrypted() {
match private_key.decrypt(password.as_bytes()) {
Ok(k) => k,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
} else {
private_key
};
match private_key.to_openssh(LineEnding::LF) {
Ok(private_key_openssh) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key_openssh.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
}),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
#[derive(PartialEq, Debug)]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported
UnsupportedKeyType,
}
pub enum SshKeyImportError {
ParsingError,
PasswordRequired,
WrongPassword,
}
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn import_key_ed25519_openssh_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_unencrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_unencrypted");
let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_encrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_encrypted");
let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted");
let public_key =
include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted");
// for whatever reason pkcs8 + rsa does not include the comment in the public key
let public_key =
include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_encrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted");
let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted_wrong_password() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::WrongPassword);
}
#[test]
fn import_non_key_error() {
let result = import_key("not a key".to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_ecdsa_error() {
let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_ed25519_putty() {
let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_rsa_openssh_putty() {
let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_key_rsa_pkcs8_putty() {
let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
}

View File

@@ -16,7 +16,6 @@ mod platform_ssh_agent;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod peercred_unix_listener_stream;
pub mod importer;
pub mod peerinfo;
mod request_parser;

View File

@@ -51,22 +51,6 @@ export declare namespace sshagent {
publicKey: string
keyFingerprint: string
}
export const enum SshKeyImportStatus {
/** ssh key was parsed correctly and will be returned in the result */
Success = 0,
/** ssh key was parsed correctly but is encrypted and requires a password */
PasswordRequired = 1,
/** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */
WrongPassword = 2,
/** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */
ParsingError = 3,
/** ssh key type is not supported (e.g. ecdsa) */
UnsupportedKeyType = 4
}
export interface SshKeyImportResult {
status: SshKeyImportStatus
sshKey?: SshKey
}
export interface SshUiRequest {
cipherId?: string
isList: boolean
@@ -79,7 +63,6 @@ export declare namespace sshagent {
export function isRunning(agentState: SshAgentState): boolean
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
export function lock(agentState: SshAgentState): void
export function importKey(encodedKey: string, password: string): SshKeyImportResult
export function clearKeys(agentState: SshAgentState): void
export class SshAgentState { }
}

View File

@@ -182,67 +182,6 @@ pub mod sshagent {
pub key_fingerprint: String,
}
impl From<desktop_core::ssh_agent::importer::SshKey> for SshKey {
fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self {
SshKey {
private_key: key.private_key,
public_key: key.public_key,
key_fingerprint: key.key_fingerprint,
}
}
}
#[napi]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported (e.g. ecdsa)
UnsupportedKeyType,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportStatus> for SshKeyImportStatus {
fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self {
match status {
desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => {
SshKeyImportStatus::Success
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => {
SshKeyImportStatus::PasswordRequired
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => {
SshKeyImportStatus::WrongPassword
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => {
SshKeyImportStatus::ParsingError
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => {
SshKeyImportStatus::UnsupportedKeyType
}
}
}
}
#[napi(object)]
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportResult> for SshKeyImportResult {
fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self {
SshKeyImportResult {
status: result.status.into(),
ssh_key: result.ssh_key.map(|k| k.into()),
}
}
}
#[napi(object)]
pub struct SshUIRequest {
pub cipher_id: Option<String>,
@@ -359,13 +298,6 @@ pub mod sshagent {
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn import_key(encoded_key: String, password: String) -> napi::Result<SshKeyImportResult> {
let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(result.into())
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;

View File

@@ -423,7 +423,7 @@
"enableHardwareAccelerationDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showSshAgentOption">
<div class="form-group">
<div class="checkbox">
<label for="enableSshAgent">
<input

View File

@@ -22,7 +22,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import {
VaultTimeout,
VaultTimeoutAction,
@@ -67,7 +66,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
showAlwaysShowDock = false;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
showSshAgentOption = false;
showOpenAtLoginOption = false;
isWindows: boolean;
isLinux: boolean;
@@ -223,7 +221,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;

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

@@ -102,6 +102,7 @@ import {
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
@@ -430,6 +431,11 @@ const safeProviders: SafeProvider[] = [
useClass: DesktopLoginApprovalComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsServiceAbstraction, I18nServiceAbstraction],
}),
];
@NgModule({

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

@@ -25,16 +25,6 @@ export class MainSshAgentService {
private logService: LogService,
private messagingService: MessagingService,
) {
ipcMain.handle(
"sshagent.importkey",
async (
event: any,
{ privateKey, password }: { privateKey: string; password?: string },
): Promise<sshagent.SshKeyImportResult> => {
return sshagent.importKey(privateKey, password);
},
);
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
this.init();
});

View File

@@ -24,8 +24,6 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
@@ -58,23 +56,13 @@ export class SshAgentService implements OnDestroy {
private toastService: ToastService,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private configService: ConfigService,
private accountService: AccountService,
) {}
async init() {
this.configService
.getFeatureFlag$(FeatureFlag.SSHAgent)
.pipe(
concatMap(async (enabled) => {
this.isFeatureFlagEnabled = enabled;
if (!(await ipc.platform.sshAgent.isLoaded()) && enabled) {
await ipc.platform.sshAgent.init();
}
}),
takeUntil(this.destroy$),
)
.subscribe();
if (!(await ipc.platform.sshAgent.isLoaded())) {
await ipc.platform.sshAgent.init();
}
await this.initListeners();
}

View File

@@ -3532,9 +3532,6 @@
"unknownApplication": {
"message": "An application"
},
"sshKeyPasswordUnsupported": {
"message": "Importing password protected SSH keys is not yet supported"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
@@ -3544,7 +3541,7 @@
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyPasted": {
"sshKeyImported": {
"message": "SSH key imported successfully"
},
"fileSavedToDevice": {

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

@@ -1,4 +1,3 @@
import { sshagent as ssh } from "desktop_native/napi";
import { ipcRenderer } from "electron";
import { DeviceType } from "@bitwarden/common/enums";
@@ -64,13 +63,6 @@ const sshAgent = {
clearKeys: async () => {
return await ipcRenderer.invoke("sshagent.clearkeys");
},
importKey: async (key: string, password: string): Promise<ssh.SshKeyImportResult> => {
const res = await ipcRenderer.invoke("sshagent.importkey", {
privateKey: key,
password: password,
});
return res;
},
isLoaded(): Promise<boolean> {
return ipcRenderer.invoke("sshagent.isloaded");
},

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

@@ -512,6 +512,15 @@
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
></i>
</button>
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'importSshKeyFromClipboard' | i18n }}"
(click)="importSshKeyFromClipboard()"
>
<i class="bwi bwi-lg bwi-paste" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
@@ -559,16 +568,6 @@
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
<button
type="button"
class="row-btn"
appStopClick
(click)="importSshKeyFromClipboard()"
>
{{ "importSshKeyFromClipboard" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -3,8 +3,6 @@
import { DatePipe } from "@angular/common";
import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { NgForm } from "@angular/forms";
import { sshagent as sshAgent } from "desktop_native/napi";
import { lastValueFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
@@ -25,8 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "AddEditComponent";
@@ -60,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
toastService: ToastService,
cipherAuthorizationService: CipherAuthorizationService,
sdkService: SdkService,
sshImportPromptService: SshImportPromptService,
) {
super(
cipherService,
@@ -82,6 +80,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
cipherAuthorizationService,
toastService,
sdkService,
sshImportPromptService,
);
}
@@ -159,69 +158,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
this.cipher.revisionDate = cipher.revisionDate;
}
async importSshKeyFromClipboard(password: string = "") {
const key = await this.platformUtilsService.readFromClipboard();
const parsedKey = await ipc.platform.sshAgent.importKey(key, password);
if (parsedKey == null) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidSshKey"),
});
return;
}
switch (parsedKey.status) {
case sshAgent.SshKeyImportStatus.ParsingError:
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidSshKey"),
});
return;
case sshAgent.SshKeyImportStatus.UnsupportedKeyType:
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyTypeUnsupported"),
});
return;
case sshAgent.SshKeyImportStatus.PasswordRequired:
case sshAgent.SshKeyImportStatus.WrongPassword:
if (password !== "") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyWrongPassword"),
});
} else {
password = await this.getSshKeyPassword();
if (password === "") {
return;
}
await this.importSshKeyFromClipboard(password);
}
return;
default:
this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey;
this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint;
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyPasted"),
});
}
}
async getSshKeyPassword(): Promise<string> {
const dialog = this.dialogService.open<string>(SshKeyPasswordPromptComponent, {
ariaModal: true,
});
return await lastValueFrom(dialog.closed);
}
truncateString(value: string, length: number) {
return value.length > length ? value.substring(0, length) + "..." : value;
}

View File

@@ -82,7 +82,6 @@
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
*ngIf="isSshKeysEnabled"
>
<span class="filter-buttons">
<button

View File

@@ -1,21 +1,13 @@
import { Component, OnInit } from "@angular/core";
import { Component } from "@angular/core";
import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@Component({
selector: "app-type-filter",
templateUrl: "type-filter.component.html",
})
export class TypeFilterComponent extends BaseTypeFilterComponent implements OnInit {
isSshKeysEnabled = false;
constructor(private configService: ConfigService) {
export class TypeFilterComponent extends BaseTypeFilterComponent {
constructor() {
super();
}
async ngOnInit(): Promise<void> {
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
}
}

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

@@ -24,6 +24,7 @@ import {
switchMap,
takeUntil,
tap,
catchError,
} from "rxjs/operators";
import {
@@ -76,6 +77,7 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import {
ResellerWarning,
ResellerWarningService,
@@ -256,6 +258,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationBillingService: OrganizationBillingServiceAbstraction,
private resellerWarningService: ResellerWarningService,
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit() {
@@ -636,12 +639,18 @@ export class VaultComponent implements OnInit, OnDestroy {
combineLatest([
of(org),
this.organizationApiService.getSubscription(org.id),
this.organizationBillingService.getPaymentSource(org.id),
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
catchError((error: unknown) => {
this.billingNotificationService.handleError(error);
return of(null);
}),
),
]),
),
map(([org, sub, paymentSource]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource);
}),
map(([org, sub, paymentSource]) =>
this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource),
),
filter((result) => result !== null),
);
this.resellerWarning$ = organization$.pipe(

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

@@ -58,6 +58,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BillingNotificationService } from "../services/billing-notification.service";
import { BillingSharedModule } from "../shared/billing-shared.module";
import { PaymentComponent } from "../shared/payment/payment.component";
@@ -208,6 +209,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
private organizationBillingService: OrganizationBillingService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit(): Promise<void> {
@@ -228,10 +230,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
try {
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
} catch (error) {
this.billingNotificationService.handleError(error);
}
}
if (!this.selfHosted) {

View File

@@ -23,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "../../services/billing-notification.service";
import { TrialFlowService } from "../../services/trial-flow.service";
import {
AddCreditDialogResult,
@@ -66,6 +67,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
private organizationService: OrganizationService,
private accountService: AccountService,
protected syncService: SyncService,
private billingNotificationService: BillingNotificationService,
) {
this.activatedRoute.params
.pipe(
@@ -115,47 +117,52 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
protected load = async (): Promise<void> => {
this.loading = true;
const { accountCredit, paymentSource, subscriptionStatus } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
try {
const { accountCredit, paymentSource, subscriptionStatus } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
);
}
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
this.loading = false;
};
protected updatePaymentMethod = async (): Promise<void> => {

View File

@@ -0,0 +1,76 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "./billing-notification.service";
describe("BillingNotificationService", () => {
let service: BillingNotificationService;
let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>;
beforeEach(() => {
logService = mock<LogService>();
toastService = mock<ToastService>();
service = new BillingNotificationService(logService, toastService);
});
describe("handleError", () => {
it("should log error and show toast for ErrorResponse", () => {
const error = new ErrorResponse(["test error"], 400);
expect(() => service.handleError(error)).toThrow();
expect(logService.error).toHaveBeenCalledWith(error);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: error.getSingleMessage(),
});
});
it("shows error toast with the provided error", () => {
const error = new ErrorResponse(["test error"], 400);
expect(() => service.handleError(error, "Test Title")).toThrow();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "Test Title",
message: error.getSingleMessage(),
});
});
it("should only log error for non-ErrorResponse", () => {
const error = new Error("test error");
expect(() => service.handleError(error)).toThrow();
expect(logService.error).toHaveBeenCalledWith(error);
expect(toastService.showToast).not.toHaveBeenCalled();
});
});
describe("showSuccess", () => {
it("shows success toast with default title when provided title is empty", () => {
const message = "test message";
service.showSuccess(message);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "",
message,
});
});
it("should show success toast with custom title", () => {
const message = "test message";
service.showSuccess(message, "Success Title");
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "Success Title",
message,
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@angular/core";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ToastService } from "@bitwarden/components";
@Injectable({
providedIn: "root",
})
export class BillingNotificationService {
constructor(
private logService: LogService,
private toastService: ToastService,
) {}
handleError(error: unknown, title: string = "") {
this.logService.error(error);
if (error instanceof ErrorResponse) {
this.toastService.showToast({
variant: "error",
title: title,
message: error.getSingleMessage(),
});
}
throw error;
}
showSuccess(message: string, title: string = "") {
this.toastService.showToast({
variant: "success",
title: title,
message: message,
});
}
}

View File

@@ -1,4 +1,8 @@
import { NgModule } from "@angular/core";
@NgModule({})
import { BillingNotificationService } from "./billing-notification.service";
@NgModule({
providers: [BillingNotificationService],
})
export class BillingServicesModule {}

View File

@@ -96,6 +96,7 @@ import {
DefaultThemeStateService,
ThemeStateService,
} from "@bitwarden/common/platform/theming/theme-state.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
KdfConfigService,
@@ -103,6 +104,7 @@ import {
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { flagEnabled } from "../../utils/flags";
import { PolicyListService } from "../admin-console/core/policy-list.service";
@@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebLoginDecryptionOptionsService,
deps: [MessagingService, RouterService, AcceptOrganizationInviteService],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -3,6 +3,7 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -75,7 +76,7 @@ export class MigrateFromLegacyEncryptionComponent {
} catch (e) {
// If the error is due to missing folders, we can delete all folders and try again
if (
e instanceof Error &&
e instanceof ErrorResponse &&
e.message === "All existing folders must be included in the rotation."
) {
const deleteFolders = await this.dialogService.openSimpleDialog({

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

@@ -13,12 +13,27 @@ import { BrowserExtensionPromptComponent } from "./browser-extension-prompt.comp
describe("BrowserExtensionPromptComponent", () => {
let fixture: ComponentFixture<BrowserExtensionPromptComponent>;
let component: BrowserExtensionPromptComponent;
const start = jest.fn();
const pageState$ = new BehaviorSubject(BrowserPromptState.Loading);
const setAttribute = jest.fn();
const getAttribute = jest.fn().mockReturnValue("width=1010");
beforeEach(async () => {
start.mockClear();
setAttribute.mockClear();
getAttribute.mockClear();
// Store original querySelector
const originalQuerySelector = document.querySelector.bind(document);
// Mock querySelector while preserving the document context
jest.spyOn(document, "querySelector").mockImplementation(function (selector) {
if (selector === 'meta[name="viewport"]') {
return { setAttribute, getAttribute } as unknown as HTMLMetaElement;
}
return originalQuerySelector.call(document, selector);
});
await TestBed.configureTestingModule({
providers: [
@@ -34,9 +49,14 @@ describe("BrowserExtensionPromptComponent", () => {
}).compileComponents();
fixture = TestBed.createComponent(BrowserExtensionPromptComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("calls start on initialization", () => {
expect(start).toHaveBeenCalledTimes(1);
});
@@ -87,6 +107,33 @@ describe("BrowserExtensionPromptComponent", () => {
const mobileText = fixture.debugElement.query(By.css("p")).nativeElement;
expect(mobileText.textContent.trim()).toBe("reopenLinkOnDesktop");
});
it("sets min-width on the body", () => {
expect(document.body.style.minWidth).toBe("auto");
});
it("stores viewport content", () => {
expect(getAttribute).toHaveBeenCalledWith("content");
expect(component["viewportContent"]).toBe("width=1010");
});
it("sets viewport meta tag to be mobile friendly", () => {
expect(setAttribute).toHaveBeenCalledWith("content", "width=device-width, initial-scale=1.0");
});
describe("on destroy", () => {
beforeEach(() => {
fixture.destroy();
});
it("resets body min-width", () => {
expect(document.body.style.minWidth).toBe("");
});
it("resets viewport meta tag", () => {
expect(setAttribute).toHaveBeenCalledWith("content", "width=1010");
});
});
});
describe("manual error state", () => {

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { CommonModule, DOCUMENT } from "@angular/common";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { ButtonComponent, IconModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -16,7 +16,7 @@ import {
standalone: true,
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule],
})
export class BrowserExtensionPromptComponent implements OnInit {
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
/** Current state of the prompt page */
protected pageState$ = this.browserExtensionPromptService.pageState$;
@@ -25,10 +25,39 @@ export class BrowserExtensionPromptComponent implements OnInit {
protected BitwardenIcon = VaultIcons.BitwardenIcon;
constructor(private browserExtensionPromptService: BrowserExtensionPromptService) {}
/** Content of the meta[name="viewport"] element */
private viewportContent: string | null = null;
constructor(
private browserExtensionPromptService: BrowserExtensionPromptService,
@Inject(DOCUMENT) private document: Document,
) {}
ngOnInit(): void {
this.browserExtensionPromptService.start();
// It is not be uncommon for users to hit this page from a mobile device.
// There are global styles and the viewport meta tag that set a min-width
// for the page which cause it to render poorly. Remove them here.
// https://github.com/bitwarden/clients/blob/main/apps/web/src/scss/base.scss#L6
this.document.body.style.minWidth = "auto";
const viewportMeta = this.document.querySelector('meta[name="viewport"]');
// Save the current viewport content to reset it when the component is destroyed
this.viewportContent = viewportMeta?.getAttribute("content") ?? null;
viewportMeta?.setAttribute("content", "width=device-width, initial-scale=1.0");
}
ngOnDestroy(): void {
// Reset the body min-width when the component is destroyed
this.document.body.style.minWidth = "";
if (this.viewportContent !== null) {
this.document
.querySelector('meta[name="viewport"]')
?.setAttribute("content", this.viewportContent);
}
}
openExtension(): void {

View File

@@ -15,7 +15,6 @@ import { isCardExpired } from "@bitwarden/common/autofill/utils";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { EventType } from "@bitwarden/common/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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -30,7 +29,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
@Component({
selector: "app-vault-add-edit",
@@ -76,6 +75,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
cipherAuthorizationService: CipherAuthorizationService,
toastService: ToastService,
sdkService: SdkService,
sshImportPromptService: SshImportPromptService,
) {
super(
cipherService,
@@ -98,6 +98,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
cipherAuthorizationService,
toastService,
sdkService,
sshImportPromptService,
);
}
@@ -105,17 +106,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
await super.ngOnInit();
await this.load();
// https://bitwarden.atlassian.net/browse/PM-10413
// cannot generate ssh keys so block creation
if (
this.type === CipherType.SshKey &&
this.cipherId == null &&
!(await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem))
) {
this.type = CipherType.Login;
this.cipher.type = CipherType.Login;
}
this.viewOnly = !this.cipher.edit && this.editMode;
// remove when all the title for all clients are updated to New Item
if (this.cloneMode || !this.editMode) {

View File

@@ -1,4 +1,4 @@
<bit-dialog dialogSize="small" background="alt">
<bit-dialog background="alt">
<span bitDialogTitle>
{{ "passwordHistory" | i18n }}
</span>

View File

@@ -7,7 +7,6 @@ import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
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";
@@ -243,20 +242,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
},
{
id: "note",
name: this.i18nService.t("typeSecureNote"),
name: this.i18nService.t("note"),
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
];
if (await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem)) {
allTypeFilters.push({
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
});
}
},
];
const typeFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
@@ -281,7 +277,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

@@ -24,6 +24,7 @@ import {
take,
takeUntil,
tap,
catchError,
} from "rxjs/operators";
import {
@@ -80,6 +81,7 @@ import {
CollectionDialogTabType,
openCollectionDialog,
} from "../../admin-console/organizations/shared/components/collection-dialog";
import { BillingNotificationService } from "../../billing/services/billing-notification.service";
import { TrialFlowService } from "../../billing/services/trial-flow.service";
import { FreeTrial } from "../../billing/types/free-trial";
import { SharedModule } from "../../shared/shared.module";
@@ -213,20 +215,25 @@ export class VaultComponent implements OnInit, OnDestroy {
ownerOrgs.map((org) =>
combineLatest([
this.organizationApiService.getSubscription(org.id),
this.organizationBillingService.getPaymentSource(org.id),
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
catchError((error: unknown) => {
this.billingNotificationService.handleError(error);
return of(null);
}),
),
]).pipe(
map(([subscription, paymentSource]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
map(([subscription, paymentSource]) =>
this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
org,
subscription,
paymentSource,
);
}),
),
),
),
),
);
}),
map((results) => results.filter((result) => result.shownBanner)),
map((results) => results.filter((result) => result !== null && result.shownBanner)),
shareReplay({ refCount: false, bufferSize: 1 }),
);
@@ -262,6 +269,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected billingApiService: BillingApiServiceAbstraction,
private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit() {

View File

@@ -28,7 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
import { AddEditComponent as BaseAddEditComponent } from "../individual-vault/add-edit.component";
@@ -64,6 +64,7 @@ export class AddEditComponent extends BaseAddEditComponent {
cipherAuthorizationService: CipherAuthorizationService,
toastService: ToastService,
sdkService: SdkService,
sshImportPromptService: SshImportPromptService,
) {
super(
cipherService,
@@ -88,6 +89,7 @@ export class AddEditComponent extends BaseAddEditComponent {
cipherAuthorizationService,
toastService,
sdkService,
sshImportPromptService,
);
}

View File

@@ -38,7 +38,7 @@
"restoreMembers": {
"message": "Restore members"
},
"cannotRestoreAccessError":{
"cannotRestoreAccessError": {
"message": "Cannot restore organization access"
},
"allApplicationsWithCount": {
@@ -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"
},
@@ -1355,8 +1371,8 @@
"yourAccountIsLocked": {
"message": "Your account is locked"
},
"uuid":{
"message" : "UUID"
"uuid": {
"message": "UUID"
},
"unlock": {
"message": "Unlock"
@@ -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."
},
@@ -5904,10 +5954,10 @@
"bulkFilteredMessage": {
"message": "Excluded, not applicable for this action"
},
"nonCompliantMembersTitle":{
"nonCompliantMembersTitle": {
"message": "Non-compliant members"
},
"nonCompliantMembersError":{
"nonCompliantMembersError": {
"message": "Members that are non-compliant with the Single organization or Two-step login policy cannot be restored until they adhere to the policy requirements"
},
"fingerprint": {
@@ -9330,7 +9380,7 @@
"message": "for Bitwarden using the implementation guide for your Identity Provider.",
"description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure single sign-on for Bitwarden using the implementation guide for your Identity Provider."
},
"userProvisioning":{
"userProvisioning": {
"message": "User provisioning"
},
"scimIntegration": {
@@ -9344,31 +9394,40 @@
"message": "(System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider.",
"description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure SCIM (System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider"
},
"bwdc":{
"bwdc": {
"message": "Bitwarden Directory Connector"
},
"bwdcDesc": {
"message": "Configure Bitwarden Directory Connector to automatically provision users and groups using the implementation guide for your Identity Provider."
},
"eventManagement":{
"eventManagement": {
"message": "Event management"
},
"eventManagementDesc":{
"eventManagementDesc": {
"message": "Integrate Bitwarden event logs with your SIEM (system information and event management) system by using the implementation guide for your platform."
},
"deviceManagement":{
"deviceManagement": {
"message": "Device management"
},
"deviceManagementDesc":{
"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"
},
"reopenLinkOnDesktop": {
"message": "Reopen this link from your email on a desktop."
},
"integrationCardTooltip":{
"integrationCardTooltip": {
"message": "Launch $INTEGRATION$ implementation guide.",
"placeholders": {
"integration": {
@@ -9377,7 +9436,7 @@
}
}
},
"smIntegrationTooltip":{
"smIntegrationTooltip": {
"message": "Set up $INTEGRATION$.",
"placeholders": {
"integration": {
@@ -9386,7 +9445,7 @@
}
}
},
"smSdkTooltip":{
"smSdkTooltip": {
"message": "View $SDK$ repository",
"placeholders": {
"sdk": {
@@ -9395,7 +9454,7 @@
}
}
},
"integrationCardAriaLabel":{
"integrationCardAriaLabel": {
"message": "open $INTEGRATION$ implementation guide in a new tab.",
"placeholders": {
"integration": {
@@ -9404,7 +9463,7 @@
}
}
},
"smSdkAriaLabel":{
"smSdkAriaLabel": {
"message": "view $SDK$ repository in a new tab.",
"placeholders": {
"sdk": {
@@ -9413,7 +9472,7 @@
}
}
},
"smIntegrationCardAriaLabel":{
"smIntegrationCardAriaLabel": {
"message": "set up $INTEGRATION$ implementation guide in a new tab.",
"placeholders": {
"integration": {
@@ -9820,7 +9879,7 @@
"message": "Config"
},
"learnMoreAboutEmergencyAccess": {
"message":"Learn more about emergency access"
"message": "Learn more about emergency access"
},
"learnMoreAboutMatchDetection": {
"message": "Learn more about match detection"
@@ -10122,7 +10181,7 @@
"selfHostingTitleProper": {
"message": "Self-Hosting"
},
"claim-domain-single-org-warning" : {
"claim-domain-single-org-warning": {
"message": "Claiming a domain will turn on the single organization policy."
},
"single-org-revoked-user-warning": {
@@ -10363,6 +10422,36 @@
"organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters."
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
},
"importSshKey": {
"message": "Import"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
},
"enterSshKeyPassword": {
"message": "Enter password"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
},
"copySSHPrivateKey": {
"message": "Copy private key"
},
"openingExtension": {
"message": "Opening the Bitwarden browser extension"
},
@@ -10528,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

@@ -81,6 +81,7 @@ const moduleRules = [
loader: "babel-loader",
options: {
configFile: "../../babel.config.json",
cacheDirectory: NODE_ENV !== "production",
},
},
],
@@ -349,6 +350,20 @@ const webpackConfig = {
styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"],
theme_head: "./src/theme.ts",
},
cache:
NODE_ENV === "production"
? false
: {
type: "filesystem",
allowCollectingMemory: true,
cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack"),
buildDependencies: {
config: [__filename],
},
},
snapshot: {
unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")],
},
optimization: {
splitChunks: {
cacheGroups: {
@@ -361,6 +376,7 @@ const webpackConfig = {
},
},
},
minimize: NODE_ENV === "production",
minimizer: [
new TerserPlugin({
terserOptions: {

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

@@ -8,6 +8,7 @@ import { map } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { InvoiceResponse } from "@bitwarden/common/billing/models/response/invoices.response";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
@Component({
templateUrl: "./provider-billing-history.component.html",
@@ -19,6 +20,7 @@ export class ProviderBillingHistoryComponent {
private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction,
private datePipe: DatePipe,
private billingNotificationService: BillingNotificationService,
) {
this.activatedRoute.params
.pipe(
@@ -30,13 +32,27 @@ export class ProviderBillingHistoryComponent {
.subscribe();
}
getClientInvoiceReport = (invoiceId: string) =>
this.billingApiService.getProviderClientInvoiceReport(this.providerId, invoiceId);
getClientInvoiceReport = async (invoiceId: string) => {
try {
return await this.billingApiService.getProviderClientInvoiceReport(
this.providerId,
invoiceId,
);
} catch (error) {
this.billingNotificationService.handleError(error);
}
};
getClientInvoiceReportName = (invoice: InvoiceResponse) => {
const date = this.datePipe.transform(invoice.date, "yyyyMMdd");
return `bitwarden_provider-billing-history_${date}_${invoice.number}`;
};
getInvoices = async () => await this.billingApiService.getProviderInvoices(this.providerId);
getInvoices = async () => {
try {
return await this.billingApiService.getProviderInvoices(this.providerId);
} catch (error) {
this.billingNotificationService.handleError(error);
}
};
}

View File

@@ -11,7 +11,8 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService } from "@bitwarden/components";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
type ManageClientSubscriptionDialogParams = {
organization: ProviderOrganizationOrganizationDetailsResponse;
@@ -56,30 +57,34 @@ export class ManageClientSubscriptionDialogComponent implements OnInit {
@Inject(DIALOG_DATA) protected dialogParams: ManageClientSubscriptionDialogParams,
private dialogRef: DialogRef<ManageClientSubscriptionDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit(): Promise<void> {
const response = await this.billingApiService.getProviderSubscription(
this.dialogParams.provider.id,
);
try {
const response = await this.billingApiService.getProviderSubscription(
this.dialogParams.provider.id,
);
this.providerPlan = response.plans.find(
(plan) => plan.planName === this.dialogParams.organization.plan,
);
this.providerPlan = response.plans.find(
(plan) => plan.planName === this.dialogParams.organization.plan,
);
this.assignedSeats = this.providerPlan.assignedSeats;
this.openSeats = this.providerPlan.seatMinimum - this.providerPlan.assignedSeats;
this.purchasedSeats = this.providerPlan.purchasedSeats;
this.seatMinimum = this.providerPlan.seatMinimum;
this.assignedSeats = this.providerPlan.assignedSeats;
this.openSeats = this.providerPlan.seatMinimum - this.providerPlan.assignedSeats;
this.purchasedSeats = this.providerPlan.purchasedSeats;
this.seatMinimum = this.providerPlan.seatMinimum;
this.formGroup.controls.assignedSeats.addValidators(
this.isServiceUserWithPurchasedSeats
? this.createPurchasedSeatsValidator()
: this.createUnassignedSeatsValidator(),
);
this.loading = false;
this.formGroup.controls.assignedSeats.addValidators(
this.isServiceUserWithPurchasedSeats
? this.createPurchasedSeatsValidator()
: this.createUnassignedSeatsValidator(),
);
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
}
submit = async () => {
@@ -91,24 +96,25 @@ export class ManageClientSubscriptionDialogComponent implements OnInit {
return;
}
const request = new UpdateClientOrganizationRequest();
request.assignedSeats = this.formGroup.value.assignedSeats;
request.name = this.dialogParams.organization.organizationName;
try {
const request = new UpdateClientOrganizationRequest();
request.assignedSeats = this.formGroup.value.assignedSeats;
request.name = this.dialogParams.organization.organizationName;
await this.billingApiService.updateProviderClientOrganization(
this.dialogParams.provider.id,
this.dialogParams.organization.id,
request,
);
await this.billingApiService.updateProviderClientOrganization(
this.dialogParams.provider.id,
this.dialogParams.organization.id,
request,
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("subscriptionUpdated"),
});
this.billingNotificationService.showSuccess(this.i18nService.t("subscriptionUpdated"));
this.loading = false;
this.dialogRef.close(this.ResultType.Submitted);
this.dialogRef.close(this.ResultType.Submitted);
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
};
createPurchasedSeatsValidator =

View File

@@ -23,6 +23,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
@@ -83,6 +84,7 @@ export class ManageClientsComponent {
private validationService: ValidationService,
private webProviderService: WebProviderService,
private configService: ConfigService,
private billingNotificationService: BillingNotificationService,
) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search);
@@ -120,13 +122,17 @@ export class ManageClientsComponent {
}
async load() {
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
this.dataSource.data = (
await this.billingApiService.getProviderClientOrganizations(this.providerId)
).data;
this.plans = (await this.billingApiService.getPlans()).data;
this.loading = false;
try {
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
this.dataSource.data = (
await this.billingApiService.getProviderClientOrganizations(this.providerId)
).data;
this.plans = (await this.billingApiService.getPlans()).data;
this.loading = false;
} catch (error) {
this.billingNotificationService.handleError(error);
}
}
addExistingOrganization = async () => {

View File

@@ -12,7 +12,7 @@ import {
ProviderSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/provider-subscription-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
@Component({
selector: "app-provider-subscription",
@@ -33,7 +33,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
private billingApiService: BillingApiServiceAbstraction,
private i18nService: I18nService,
private route: ActivatedRoute,
private toastService: ToastService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit() {
@@ -54,20 +54,26 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
return;
}
this.loading = true;
this.subscription = await this.billingApiService.getProviderSubscription(this.providerId);
this.totalCost =
((100 - this.subscription.discountPercentage) / 100) * this.sumCost(this.subscription.plans);
this.loading = false;
try {
this.subscription = await this.billingApiService.getProviderSubscription(this.providerId);
this.totalCost =
((100 - this.subscription.discountPercentage) / 100) *
this.sumCost(this.subscription.plans);
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
}
protected updateTaxInformation = async (taxInformation: TaxInformation) => {
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedTaxInformation"),
});
try {
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
this.billingNotificationService.showSuccess(this.i18nService.t("updatedTaxInformation"));
} catch (error) {
this.billingNotificationService.handleError(error);
}
};
protected getFormattedCost(

View File

@@ -16,6 +16,8 @@ import {
firstValueFrom,
of,
filter,
catchError,
from,
} from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
@@ -32,6 +34,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service";
import { FreeTrial } from "@bitwarden/web-vault/app/billing/types/free-trial";
@@ -126,6 +129,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
private organizationApiService: OrganizationApiServiceAbstraction,
private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
) {}
ngOnInit() {
@@ -161,12 +165,18 @@ export class OverviewComponent implements OnInit, OnDestroy {
combineLatest([
of(org),
this.organizationApiService.getSubscription(org.id),
this.organizationBillingService.getPaymentSource(org.id),
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
catchError((error: unknown) => {
this.billingNotificationService.handleError(error);
return of(null);
}),
),
]),
),
map(([org, sub, paymentSource]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource);
}),
filter((result) => result !== null),
takeUntil(this.destroy$),
);

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

@@ -304,6 +304,8 @@ import {
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
DefaultTaskService,
DefaultEndUserNotificationService,
EndUserNotificationService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
@@ -317,6 +319,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";
@@ -1282,7 +1286,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: BillingApiServiceAbstraction,
useClass: BillingApiService,
deps: [ApiServiceAbstraction, LogService, ToastService],
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: TaxServiceAbstraction,
@@ -1463,6 +1467,21 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
}),
safeProvider({
provide: EndUserNotificationService,
useClass: DefaultEndUserNotificationService,
deps: [StateProvider, ApiServiceAbstraction],
}),
safeProvider({
provide: DeviceTrustToastServiceAbstraction,
useClass: DeviceTrustToastService,
deps: [
AuthRequestServiceAbstraction,
DeviceTrustServiceAbstraction,
I18nServiceAbstraction,
ToastService,
],
}),
];
@NgModule({

View File

@@ -15,7 +15,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -41,7 +40,7 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
@Directive()
export class AddEditComponent implements OnInit, OnDestroy {
@@ -131,7 +130,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected configService: ConfigService,
protected cipherAuthorizationService: CipherAuthorizationService,
protected toastService: ToastService,
private sdkService: SdkService,
protected sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
) {
this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
@@ -207,10 +207,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
if (sshKeysEnabled) {
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
ngOnDestroy() {
@@ -824,6 +821,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
return true;
}
async importSshKeyFromClipboard() {
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
if (key != null) {
this.cipher.sshKey.privateKey = key.privateKey;
this.cipher.sshKey.publicKey = key.publicKey;
this.cipher.sshKey.keyFingerprint = key.keyFingerprint;
}
}
private async generateSshKey(showNotification: boolean = true) {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");

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

@@ -13,6 +13,8 @@
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/importer-ui": ["../importer/src/components"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],

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

@@ -226,20 +226,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";
@@ -311,26 +297,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());
@@ -407,29 +373,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(
@@ -437,7 +381,6 @@ describe("TwoFactorAuthComponent", () => {
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});

Some files were not shown because too many files have changed in this diff Show More