1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-16 08:34:39 +00:00

Merge branch 'main' into PM-16921

This commit is contained in:
Conner Turnbull
2025-04-14 15:09:53 -04:00
committed by GitHub
1412 changed files with 48839 additions and 44496 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs";
@@ -18,7 +17,13 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { SharedModule } from "../../../../shared";
import { GroupApiService, GroupView } from "../../core";

View File

@@ -1,13 +0,0 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module";
import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module";
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";
@NgModule({
imports: [SharedModule, PipesModule],
declarations: [CollectionNameBadgeComponent],
exports: [CollectionNameBadgeComponent],
})
export class CollectionBadgeModule {}

View File

@@ -4,9 +4,14 @@ import { Component, Input } from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
import { SharedModule } from "../../../../shared/shared.module";
import { GetCollectionNameFromIdPipe } from "../pipes";
@Component({
selector: "app-collection-badge",
templateUrl: "collection-name-badge.component.html",
standalone: true,
imports: [SharedModule, GetCollectionNameFromIdPipe],
})
export class CollectionNameBadgeComponent {
@Input() collectionIds: string[];

View File

@@ -0,0 +1 @@
export * from "./collection-name.badge.component";

View File

@@ -0,0 +1,2 @@
export * from "./utils";
export * from "./collection-badge";

View File

@@ -5,6 +5,7 @@ import { CollectionView } from "@bitwarden/admin-console/common";
@Pipe({
name: "collectionNameFromId",
pure: true,
standalone: true,
})
export class GetCollectionNameFromIdPipe implements PipeTransform {
transform(value: string, collections: CollectionView[]) {

View File

@@ -0,0 +1 @@
export * from "./get-collection-name.pipe";

View File

@@ -0,0 +1 @@
export * from "./collection-utils";

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -48,6 +49,7 @@ export class VaultFilterComponent
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
) {
super(
vaultFilterService,
@@ -58,6 +60,7 @@ export class VaultFilterComponent
billingApiService,
dialogService,
configService,
accountService,
);
}
@@ -86,8 +89,8 @@ export class VaultFilterComponent
const collapsedNodes = await firstValueFrom(this.vaultFilterService.collapsedFilterNodes$);
collapsedNodes.delete("AllCollections");
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes);
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes, userId);
}
protected async addCollectionFilter(): Promise<VaultFilterSection> {

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-full' : '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

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import {
@@ -24,6 +23,7 @@ import {
switchMap,
takeUntil,
tap,
catchError,
} from "rxjs/operators";
import {
@@ -44,7 +44,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -62,6 +61,7 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import {
DialogRef,
BannerModule,
DialogService,
Icons,
@@ -76,6 +76,7 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import {
ResellerWarning,
ResellerWarningService,
@@ -109,7 +110,6 @@ import {
} from "../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { VaultFilter } from "../../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
import { getNestedCollectionTree } from "../../../vault/utils/collection-utils";
import { GroupApiService, GroupView } from "../core";
import { openEntityEventsDialog } from "../manage/entity-events.component";
import {
@@ -123,6 +123,7 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree } from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -194,7 +195,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private resellerManagedOrgAlert: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
@@ -256,15 +256,12 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationBillingService: OrganizationBillingServiceAbstraction,
private resellerWarningService: ResellerWarningService,
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
@@ -636,16 +633,22 @@ 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(
filter((org) => org.isOwner && this.resellerManagedOrgAlert),
filter((org) => org.isOwner),
switchMap((org) =>
from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
map((metadata) => ({ org, metadata })),
@@ -1223,6 +1226,7 @@ export class VaultComponent implements OnInit, OnDestroy {
organizationId: this.organization?.id,
parentCollectionId: this.selectedCollection?.node.id,
limitNestedCollections: !this.organization.canEditAnyCollection,
isAdminConsoleActive: true,
},
});
@@ -1248,6 +1252,7 @@ export class VaultComponent implements OnInit, OnDestroy {
readonly: readonly,
isAddAccessCollection: c.unmanaged,
limitNestedCollections: !this.organization.canEditAnyCollection,
isAdminConsoleActive: true,
},
});

View File

@@ -6,7 +6,7 @@ import { OrganizationBadgeModule } from "../../../vault/individual-vault/organiz
import { ViewComponent } from "../../../vault/individual-vault/view.component";
import { CollectionDialogComponent } from "../shared/components/collection-dialog";
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
import { CollectionNameBadgeComponent } from "./collection-badge";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
@@ -17,7 +17,7 @@ import { VaultComponent } from "./vault.component";
SharedModule,
LooseComponentsModule,
GroupBadgeModule,
CollectionBadgeModule,
CollectionNameBadgeComponent,
OrganizationBadgeModule,
CollectionDialogComponent,
VaultComponent,

View File

@@ -17,6 +17,7 @@ export class OrganizationUserAdminView {
type: OrganizationUserType;
status: OrganizationUserStatusType;
externalId: string;
ssoExternalId: string;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
hasMasterPassword: boolean;
@@ -39,6 +40,7 @@ export class OrganizationUserAdminView {
view.type = response.type;
view.status = response.status;
view.externalId = response.externalId;
view.ssoExternalId = response.ssoExternalId;
view.permissions = response.permissions;
view.resetPasswordEnrolled = response.resetPasswordEnrolled;
view.collections = response.collections.map((c) => ({

View File

@@ -59,7 +59,7 @@ export function isEnterpriseOrgGuard(showError: boolean = true): CanActivateFn {
content: { key: "onlyAvailableForEnterpriseOrganization" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
icon: "bwi-arrow-circle-up",
icon: "bwi-plus-circle",
});
if (upgradeConfirmed) {
await router.navigate(["organizations", org.id, "billing", "subscription"], {

View File

@@ -58,7 +58,7 @@ export function isPaidOrgGuard(): CanActivateFn {
content: { key: "upgradeOrganizationCloseSecurityGapsDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
icon: "bwi-arrow-circle-up",
icon: "bwi-plus-circle",
});
if (upgradeConfirmed) {
await router.navigate(["organizations", org.id, "billing", "subscription"], {

View File

@@ -64,7 +64,7 @@
</ng-container>
</bit-nav-group>
<bit-nav-item
icon="bwi-providers"
icon="bwi-msp"
[text]="'integrations' | i18n"
route="integrations"
*ngIf="integrationPageEnabled$ | async"

View File

@@ -118,7 +118,10 @@ export class OrganizationLayoutComponent implements OnInit {
),
);
this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg);
this.hideNewOrgButton$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
);
const provider$ = this.organization$.pipe(
switchMap((organization) => this.providerService.get$(organization.providerId)),

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
@@ -15,7 +14,13 @@ import { EventView } from "@bitwarden/common/models/view/event.view";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogService,
TableDataSource,
ToastService,
} from "@bitwarden/components";
import { EventService } from "../../../core";
import { SharedModule } from "../../../shared";

View File

@@ -111,10 +111,10 @@
<ng-container *ngIf="loaded && usePlaceHolderEvents">
<div
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center"
class="tw-relative tw--top-72 tw-bg-background tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center tw-h-[19rem]"
>
<div
class="tw-bg-[#ffffff] tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"
class="tw-bg-background tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"
>
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>

View File

@@ -9,7 +9,7 @@
<div bitDialogContent>
<div *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>
@@ -23,7 +23,7 @@
<input bitInput appAutofocus type="text" formControlName="name" />
<bit-hint>{{ "characterMaximum" | i18n: 100 }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-form-field *ngIf="isExternalIdVisible$ | async">
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import {
@@ -29,12 +28,20 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { InternalGroupApiService as GroupService } from "../core";
import {
@@ -215,6 +222,10 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupDetails$,
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
protected isExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(map((isEnabled) => !isEnabled || !!this.groupForm.get("externalId")?.value));
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
@@ -231,6 +242,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private collectionAdminService: CollectionAdminService,
private toastService: ToastService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}

View File

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

View File

@@ -0,0 +1,23 @@
<form [formGroup]="confirmForm" [bitSubmit]="submit">
<bit-dialog
dialogSize="large"
[loading]="loading"
[title]="'trustOrganization' | i18n"
[subtitle]="params.name"
>
<ng-container bitDialogContent>
<bit-callout type="warning">{{ "orgTrustWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "fingerprintPhrase" | i18n }} <code>{{ fingerprint }}</code>
</p>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" bitButton bitFormButton>
<span>{{ "trust" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "doNotTrust" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,69 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
type OrganizationTrustDialogData = {
/** display name of the organization */
name: string;
/** identifies the organization */
orgId: string;
/** org public key */
publicKey: Uint8Array;
};
@Component({
selector: "organization-trust",
templateUrl: "organization-trust.component.html",
})
export class OrganizationTrustComponent implements OnInit {
loading = true;
fingerprint: string = "";
confirmForm = this.formBuilder.group({});
constructor(
@Inject(DIALOG_DATA) protected params: OrganizationTrustDialogData,
private formBuilder: FormBuilder,
private keyService: KeyService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private logService: LogService,
private dialogRef: DialogRef<boolean>,
) {}
async ngOnInit() {
try {
const fingerprint = await this.keyService.getFingerprint(
this.params.orgId,
this.params.publicKey,
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
} catch (e) {
this.logService.error(e);
}
this.loading = false;
}
submit = async () => {
if (this.loading) {
return;
}
this.dialogRef.close(true);
};
/**
* Strongly typed helper to open a OrganizationTrustComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data The data to pass to the dialog
*/
static open(dialogService: DialogService, data: OrganizationTrustDialogData) {
return dialogService.open<boolean, OrganizationTrustDialogData>(OrganizationTrustComponent, {
data,
});
}
}

View File

@@ -1,12 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
export type UserConfirmDialogData = {

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
@@ -21,7 +20,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
@@ -9,7 +8,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
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 { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { DeleteManagedMemberWarningService } from "../../services/delete-managed-member/delete-managed-member-warning.service";

View File

@@ -1,12 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import {
DialogRef,
DIALOG_DATA,
DialogService,
TableDataSource,
ToastService,
} from "@bitwarden/components";
import { OrganizationUserView } from "../../../core";

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import {
@@ -10,7 +9,7 @@ import {
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { BaseBulkRemoveComponent } from "./base-bulk-remove.component";
import { BulkUserDetails } from "./bulk-status.component";

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { Observable } from "rxjs";
@@ -9,7 +8,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
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 { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogService } from "@bitwarden/components";
import { BulkUserDetails } from "./bulk-status.component";

View File

@@ -18,7 +18,9 @@
</td>
<td bitCell>
{{ item.user.email }}
<small class="text-muted d-block" *ngIf="item.user.name">{{ item.user.name }}</small>
<small class="tw-text-muted tw-block" *ngIf="item.user.name">{{
item.user.name
}}</small>
</td>
<td class="tw-text-danger" *ngIf="item.error" bitCell>
{{ item.message }}

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { OrganizationUserBulkResponse } from "@bitwarden/admin-console/common";
@@ -13,7 +12,7 @@ import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { OrganizationUserView } from "../../../core/views/organization-user.view";

View File

@@ -12,7 +12,7 @@
<div bitDialogContent>
<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>
@@ -177,11 +177,17 @@
</bit-label>
</bit-form-control>
</ng-container>
<bit-form-field>
<bit-form-field *ngIf="isExternalIdVisible$ | async">
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field *ngIf="isSsoExternalIdVisible$ | async">
<bit-label>{{ "ssoExternalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="ssoExternalId" />
<bit-hint>{{ "ssoExternalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
</bit-tab>
<bit-tab *ngIf="organization.useGroups" [label]="'groups' | i18n">
<div class="tw-mb-6">

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import {
@@ -37,7 +36,13 @@ 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 { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
GroupApiService,
@@ -120,6 +125,7 @@ export class MemberDialogComponent implements OnDestroy {
emails: [""],
type: OrganizationUserType.User,
externalId: this.formBuilder.control({ value: "", disabled: true }),
ssoExternalId: this.formBuilder.control({ value: "", disabled: true }),
accessSecretsManager: false,
access: [[] as AccessItemValue[]],
groups: [[] as AccessItemValue[]],
@@ -150,6 +156,22 @@ export class MemberDialogComponent implements OnDestroy {
FeatureFlag.AccountDeprovisioning,
);
protected isExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(
map((isEnabled) => {
return !isEnabled || !!this.formGroup.get("externalId")?.value;
}),
);
protected isSsoExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(
map((isEnabled) => {
return isEnabled && !!this.formGroup.get("ssoExternalId")?.value;
}),
);
private destroy$ = new Subject<void>();
get customUserTypeSelected(): boolean {
@@ -397,6 +419,7 @@ export class MemberDialogComponent implements OnDestroy {
this.formGroup.patchValue({
type: userDetails.type,
externalId: userDetails.externalId,
ssoExternalId: userDetails.ssoExternalId,
access: accessSelections,
accessSecretsManager: userDetails.accessSecretsManager,
groups: groupAccessSelections,

View File

@@ -1,18 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { Subject, switchMap, takeUntil } from "rxjs";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { OrganizationUserResetPasswordService } from "../services/organization-user-reset-password/organization-user-reset-password.service";
@@ -81,12 +88,16 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<ResetPasswordDialogResult>,
private accountService: AccountService,
) {}
async ngOnInit() {
this.policyService
.masterPasswordPolicyOptions$()
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions = enforcedPasswordPolicyOptions),

View File

@@ -48,7 +48,7 @@
</div>
<ng-container *ngIf="!firstLoaded">
<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>
@@ -187,7 +187,7 @@
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
@@ -196,22 +196,25 @@
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>{{ "invited" | i18n }}</span
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>{{ "needsConfirmation" | i18n }}</span
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>{{ "revoked" | i18n }}</span
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}

View File

@@ -43,6 +43,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -168,15 +169,18 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager));
const policies$ = organization$.pipe(
switchMap((organization) => {
const policies$ = combineLatest([
this.accountService.activeAccount$.pipe(getUserId),
organization$,
]).pipe(
switchMap(([userId, organization]) => {
if (organization.isProviderUser) {
return from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
);
}
return this.policyService.policies$;
return this.policyService.policies$(userId);
}),
);
@@ -663,9 +667,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.organization.id,
filteredUsers.map((user) => user.id),
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
// Bulk Status component open
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {

View File

@@ -50,7 +50,7 @@ export class DeleteManagedMemberWarningService {
key: "deleteManagedUserWarningDesc",
},
type: "danger",
icon: "bwi-exclamation-circle",
icon: "bwi-exclamation-triangle",
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});

View File

@@ -0,0 +1,11 @@
export class OrganizationUserResetPasswordEntry {
orgId: string;
publicKey: Uint8Array;
orgName: string;
constructor(orgId: string, publicKey: Uint8Array, orgName: string) {
this.orgId = orgId;
this.publicKey = publicKey;
this.orgName = orgName;
}
}

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
@@ -14,6 +14,7 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -23,6 +24,9 @@ import { KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
const mockPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
describe("OrganizationUserResetPasswordService", () => {
let sut: OrganizationUserResetPasswordService;
@@ -51,6 +55,21 @@ describe("OrganizationUserResetPasswordService", () => {
);
});
beforeEach(() => {
organizationService.organizations$.mockReturnValue(
new BehaviorSubject([
createOrganization("1", "org1", true),
createOrganization("2", "org2", false),
]),
);
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "privateKey",
publicKey: "publicKey",
}),
);
});
afterEach(() => {
jest.resetAllMocks();
});
@@ -59,55 +78,47 @@ describe("OrganizationUserResetPasswordService", () => {
expect(sut).toBeTruthy();
});
describe("getRecoveryKey", () => {
describe("buildRecoveryKey", () => {
const mockOrgId = "test-org-id";
beforeEach(() => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
keyService.getUserKey.mockResolvedValue(mockUserKey);
encryptService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
});
it("should return an encrypted user key", async () => {
const encryptedString = await sut.buildRecoveryKey(mockOrgId);
const encryptedString = await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptedString).toBeDefined();
});
it("should only use the user key from memory if one is not provided", async () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
await sut.buildRecoveryKey(mockOrgId, mockUserKey);
expect(keyService.getUserKey).not.toHaveBeenCalled();
});
it("should throw an error if the organization keys are null", async () => {
organizationApiService.getKeys.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
await expect(sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys)).rejects.toThrow();
});
it("should throw an error if the user key can't be found", async () => {
keyService.getUserKey.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
await expect(sut.buildRecoveryKey(mockOrgId, null, mockPublicKeys)).rejects.toThrow();
});
it("should rsa encrypt the user key", async () => {
await sut.buildRecoveryKey(mockOrgId);
await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(expect.anything(), expect.anything());
});
it("should throw an error if the public key is not trusted", async () => {
await expect(
sut.buildRecoveryKey(mockOrgId, mockUserKey, [new Uint8Array(64)]),
).rejects.toThrow();
});
});
describe("resetMasterPassword", () => {
@@ -163,6 +174,20 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("getPublicKeys", () => {
it("should return public keys for organizations that have reset password enrolled", async () => {
const result = await sut.getPublicKeys("userId" as UserId);
expect(result).toHaveLength(1);
});
it("should result should contain the correct data for the org", async () => {
const result = await sut.getPublicKeys("userId" as UserId);
expect(result[0].orgId).toBe("1");
expect(result[0].orgName).toBe("org1");
expect(result[0].publicKey).toEqual(Utils.fromB64ToArray("publicKey"));
});
});
describe("getRotatedData", () => {
beforeEach(() => {
organizationService.organizations$.mockReturnValue(
@@ -171,7 +196,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
encryptService.rsaEncrypt.mockResolvedValue(
@@ -182,7 +207,7 @@ describe("OrganizationUserResetPasswordService", () => {
it("should return all re-encrypted account recovery keys", async () => {
const result = await sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
mockPublicKeys,
"mockUserId" as UserId,
);
@@ -191,22 +216,18 @@ describe("OrganizationUserResetPasswordService", () => {
it("throws if the new user key is null", async () => {
await expect(
sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
null,
"mockUserId" as UserId,
),
sut.getRotatedData(null, mockPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});
});
function createOrganization(id: string, name: string) {
function createOrganization(id: string, name: string, resetPasswordEnrolled = true): Organization {
const org = new Organization();
org.id = id;
org.name = name;
org.identifier = name;
org.isMember = true;
org.resetPasswordEnrolled = true;
org.resetPasswordEnrolled = resetPasswordEnrolled;
return org;
}

View File

@@ -21,16 +21,22 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
UserKeyRotationDataProvider,
UserKeyRotationKeyRecoveryProvider,
KeyService,
KdfType,
} from "@bitwarden/key-management";
import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-password-entry";
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
implements
UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
>
{
constructor(
private keyService: KeyService,
@@ -42,11 +48,17 @@ export class OrganizationUserResetPasswordService
) {}
/**
* Returns the user key encrypted by the organization's public key.
* Intended for use in enrollment
* Builds a recovery key for a user to recover their account.
*
* @param orgId desired organization
* @param userKey user key
* @param trustedPublicKeys public keys of organizations that the user trusts
*/
async buildRecoveryKey(orgId: string, userKey?: UserKey): Promise<EncryptedString> {
async buildRecoveryKey(
orgId: string,
userKey: UserKey,
trustedPublicKeys: Uint8Array[],
): Promise<EncryptedString> {
// Retrieve Public Key
const orgKeys = await this.organizationApiService.getKeys(orgId);
if (orgKeys == null) {
@@ -55,13 +67,16 @@ export class OrganizationUserResetPasswordService
const publicKey = Utils.fromB64ToArray(orgKeys.publicKey);
// RSA Encrypt user key with organization's public key
userKey ??= await this.keyService.getUserKey();
if (userKey == null) {
throw new Error("No user key found");
if (
!trustedPublicKeys.some(
(key) => Utils.fromBufferToHex(key) === Utils.fromBufferToHex(publicKey),
)
) {
throw new Error("Untrusted public key");
}
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
// RSA Encrypt user key with organization's public key
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
return encryptedKey.encryptedString;
}
@@ -138,6 +153,21 @@ export class OrganizationUserResetPasswordService
);
}
async getPublicKeys(userId: UserId): Promise<OrganizationUserResetPasswordEntry[]> {
const allOrgs = (await firstValueFrom(this.organizationService.organizations$(userId))).filter(
(org) => org.resetPasswordEnrolled,
);
const entries: OrganizationUserResetPasswordEntry[] = [];
for (const org of allOrgs) {
const publicKey = await this.organizationApiService.getKeys(org.id);
const encodedPublicKey = Utils.fromB64ToArray(publicKey.publicKey);
const entry = new OrganizationUserResetPasswordEntry(org.id, encodedPublicKey, org.name);
entries.push(entry);
}
return entries;
}
/**
* Returns existing account recovery keys re-encrypted with the new user key.
* @param originalUserKey the original user key
@@ -147,8 +177,8 @@ export class OrganizationUserResetPasswordService
* @returns a list of account recovery keys that have been re-encrypted with the new user key
*/
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
if (newUserKey == null) {
@@ -156,9 +186,8 @@ export class OrganizationUserResetPasswordService
}
const allOrgs = await firstValueFrom(this.organizationService.organizations$(userId));
if (!allOrgs) {
return;
throw new Error("Could not get organizations");
}
const requests: OrganizationUserResetPasswordWithIdRequest[] = [];
@@ -169,7 +198,7 @@ export class OrganizationUserResetPasswordService
}
// Re-enroll - encrypt user key with organization public key
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey);
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey, trustedPublicKeys);
// Create/Execute request
const request = new OrganizationUserResetPasswordWithIdRequest();

View File

@@ -25,7 +25,6 @@ import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.compo
selector: "app-org-policies",
templateUrl: "policies.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class PoliciesComponent implements OnInit {
@ViewChild("editTemplate", { read: ViewContainerRef, static: true })
editModalRef: ViewContainerRef;

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import {
AfterViewInit,
ChangeDetectorRef,
@@ -17,7 +16,13 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { BasePolicy, BasePolicyComponent } from "../policies";

View File

@@ -2,7 +2,7 @@
{{ "keyConnectorPolicyRestriction" | i18n }}
</bit-callout>
<bit-callout type="success" [title]="'prerequisite' | i18n" icon="bwi-lightbulb">
<bit-callout type="info" [title]="'prerequisite' | i18n">
{{ "accountRecoverySingleOrgRequirementDesc" | i18n }}
</bit-callout>

View File

@@ -42,12 +42,14 @@
{{ "learnMoreAboutApi" | i18n }}
</a>
</p>
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
<div class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
</div>
</ng-container>
<form
*ngIf="org && !loading"
@@ -69,7 +71,7 @@
<input type="checkbox" bitCheckbox formControlName="limitCollectionDeletion" />
</bit-form-control>
<bit-form-control *ngIf="limitItemDeletionFeatureFlagIsEnabled">
<bit-label>{{ "limitItemDeletionDesc" | i18n }}</bit-label>
<bit-label>{{ "limitItemDeletionDescription" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitItemDeletion" />
</bit-form-control>
<button

View File

@@ -28,13 +28,13 @@
</p>
<app-user-verification formControlName="secret"> </app-user-verification>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="danger" [disabled]="!loaded">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { combineLatest, firstValueFrom, Subject, takeUntil } from "rxjs";
@@ -21,7 +20,13 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { UserVerificationModule } from "../../../../auth/shared/components/user-verification";
import { SharedModule } from "../../../../shared/shared.module";

View File

@@ -1,5 +1,7 @@
import { NgModule } from "@angular/core";
import { ItemModule } from "@bitwarden/components";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { PoliciesModule } from "../../organizations/policies";
@@ -15,6 +17,7 @@ import { TwoFactorSetupComponent } from "./two-factor-setup.component";
PoliciesModule,
OrganizationSettingsRoutingModule,
AccountFingerprintComponent,
ItemModule,
],
declarations: [AccountComponent, TwoFactorSetupComponent],
})

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs";
@@ -21,7 +20,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogService } from "@bitwarden/components";
import { DialogRef, DialogService } from "@bitwarden/components";
import { TwoFactorSetupDuoComponent } from "../../../auth/settings/two-factor/two-factor-setup-duo.component";
import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor/two-factor-setup.component";
@@ -31,7 +30,6 @@ import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor/two-
selector: "app-two-factor-setup",
templateUrl: "../../../auth/settings/two-factor/two-factor-setup.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent implements OnInit {
tabbedHeader = false;
constructor(

View File

@@ -35,7 +35,7 @@
</bit-select>
</bit-form-field>
<bit-form-field>
<bit-form-field *ngIf="isExternalIdVisible$ | async">
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
@@ -124,7 +124,7 @@
buttonType="primary"
[disabled]="loading || dialogReadonly"
>
{{ "save" | i18n }}
{{ buttonDisplayName | i18n }}
</button>
<button
type="button"

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { AbstractControl, FormBuilder, Validators } from "@angular/forms";
import {
@@ -13,6 +12,8 @@ import {
Subject,
switchMap,
takeUntil,
tap,
filter,
} from "rxjs";
import { first } from "rxjs/operators";
@@ -24,6 +25,8 @@ import {
OrganizationUserUserMiniResponse,
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -32,13 +35,25 @@ 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 {
DIALOG_DATA,
DialogConfig,
DialogRef,
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 +70,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;
@@ -67,6 +95,7 @@ export interface CollectionDialogParams {
limitNestedCollections?: boolean;
readonly?: boolean;
isAddAccessCollection?: boolean;
isAdminConsoleActive?: boolean;
}
export interface CollectionDialogResult {
@@ -78,6 +107,7 @@ export enum CollectionDialogAction {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
Upgrade = "upgrade",
}
@Component({
@@ -107,6 +137,19 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
protected isExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(
map((isEnabled) => {
return (
!isEnabled ||
(!!this.params.isAdminConsoleActive && !!this.formGroup.get("externalId")?.value)
);
}),
);
private orgExceedingCollectionLimit!: Organization;
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
@@ -122,6 +165,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 +196,42 @@ 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(
tap((_) => {
if (this.organizationSelected.errors?.cannotCreateCollections) {
this.buttonDisplayName = ButtonType.Upgrade;
} else {
this.buttonDisplayName = ButtonType.Save;
}
}),
filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)),
takeUntil(this.destroy$),
)
.subscribe((org) => {
this.orgExceedingCollectionLimit = org;
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) {
@@ -263,6 +344,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
get organizationSelected() {
return this.formGroup.controls.selectedOrg;
}
protected get collectionId() {
return this.params.collectionId;
}
@@ -287,6 +372,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 +460,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 &&
@@ -388,7 +489,18 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.controls.access.disable();
} else {
this.formGroup.controls.name.enable();
this.formGroup.controls.externalId.enable();
this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
if (isEnabled) {
this.formGroup.controls.externalId.disable();
} else {
this.formGroup.controls.externalId.enable();
}
});
this.formGroup.controls.parent.enable();
this.formGroup.controls.access.enable();
}

View File

@@ -61,11 +61,10 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
if (theme === ThemeType.System) {
// When the user's preference is the system theme,
// use the system theme to determine the image
const prefersDarkMode =
systemTheme === ThemeType.Dark || systemTheme === ThemeType.SolarizedDark;
const prefersDarkMode = systemTheme === ThemeType.Dark;
this.imageEle.nativeElement.src = prefersDarkMode ? this.imageDarkMode : this.image;
} else if (theme === ThemeType.Dark || theme === ThemeType.SolarizedDark) {
} else if (theme === ThemeType.Dark) {
// When the user's preference is dark mode, use the dark mode image
this.imageEle.nativeElement.src = this.imageDarkMode;
} else {

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

@@ -1,13 +1,13 @@
<div class="mt-5 d-flex justify-content-center" *ngIf="loading">
<div class="tw-mt-10 tw-flex tw-justify-center" *ngIf="loading">
<div>
<img class="mb-4 logo logo-themed" alt="Bitwarden" />
<p class="text-center">
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="logo"></bit-icon>
<div class="tw-flex tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</p>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { Component, inject } from "@angular/core";
import { Params } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/auth/angular";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -20,6 +21,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
templateUrl: "accept-family-sponsorship.component.html",
})
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
protected logo = BitwardenLogo;
failedShortMessage = "inviteAcceptFailedShort";
failedMessage = "inviteAcceptFailed";

View File

@@ -29,7 +29,7 @@
<div *ngIf="showNewOrganization">
<app-organization-plans></app-organization-plans>
</div>
<div class="w-1/2" *ngIf="!showNewOrganization">
<div class="tw-w-1/2" *ngIf="!showNewOrganization">
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "acceptOffer" | i18n }}
</button>

View File

@@ -1,19 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, lastValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../manage/organization-trust.component";
import { OrganizationUserResetPasswordService } from "../members/services/organization-user-reset-password/organization-user-reset-password.service";
interface EnrollMasterPasswordResetData {
@@ -28,12 +34,14 @@ export class EnrollMasterPasswordReset {
data: EnrollMasterPasswordResetData,
resetPasswordService: OrganizationUserResetPasswordService,
organizationUserApiService: OrganizationUserApiService,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
syncService: SyncService,
logService: LogService,
userVerificationService: UserVerificationService,
toastService: ToastService,
keyService: KeyService,
accountService: AccountService,
organizationApiService: OrganizationApiServiceAbstraction,
) {
const result = await UserVerificationDialogComponent.open(dialogService, {
title: "enrollAccountRecovery",
@@ -44,12 +52,33 @@ export class EnrollMasterPasswordReset {
verificationType: {
type: "custom",
verificationFn: async (secret: VerificationWithSecret) => {
const activeUserId = (await firstValueFrom(accountService.activeAccount$)).id;
const publicKey = Utils.fromB64ToArray(
(await organizationApiService.getKeys(data.organization.id)).publicKey,
);
const request =
await userVerificationService.buildRequest<OrganizationUserResetPasswordEnrollmentRequest>(
secret,
);
const dialogRef = OrganizationTrustComponent.open(dialogService, {
name: data.organization.name,
orgId: data.organization.id,
publicKey,
});
const result = await lastValueFrom(dialogRef.closed);
if (result !== true) {
throw new Error("Organization not trusted, aborting user key rotation");
}
const trustedOrgPublicKeys = [publicKey];
const userKey = await firstValueFrom(keyService.userKey$(activeUserId));
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(
data.organization.id,
userKey,
trustedOrgPublicKeys,
);
// Process the enrollment request, which is an endpoint that is

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";
@@ -14,10 +16,10 @@ import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.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) => {
@@ -173,8 +178,6 @@ export class AppComponent implements OnDestroy, OnInit {
type: "success",
});
if (premiumConfirmed) {
// 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
await this.router.navigate(["settings/subscription/premium"]);
}
break;
@@ -359,12 +362,8 @@ export class AppComponent implements OnDestroy, OnInit {
private idleStateChanged() {
if (this.isIdle) {
// 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.notificationsService.disconnectFromInactivity();
} else {
// 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.notificationsService.reconnectFromActivity();
}
}

View File

@@ -4,3 +4,4 @@ export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
export * from "./two-factor-auth";
export * from "./link-sso.service";

View File

@@ -0,0 +1,154 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
import { LinkSsoService } from "./link-sso.service";
describe("LinkSsoService", () => {
let sut: LinkSsoService;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockApiService: MockProxy<ApiService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
const mockEnvironment$ = new BehaviorSubject<any>({
getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"),
});
beforeEach(() => {
// Create mock implementations
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
mockApiService = mock<ApiService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockEnvironmentService = mock<EnvironmentService>();
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
// Set up environment service to return our mock environment
mockEnvironmentService.environment$ = mockEnvironment$;
// Set up API service mocks
const mockResponse = { Token: "mockSsoToken" };
mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse));
mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier");
// Set up password generation service mock
mockPasswordGenerationService.generatePassword.mockImplementation(
async (options: PasswordGeneratorOptions) => {
return "mockGeneratedPassword";
},
);
// Set up crypto function service mock
mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4]));
// Create the service under test with mock dependencies
sut = new LinkSsoService(
mockSsoLoginService,
mockApiService,
mockCryptoFunctionService,
mockEnvironmentService,
mockPasswordGenerationService,
mockPlatformUtilsService,
);
// Mock Utils.fromBufferToUrlB64
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge");
// Mock window.location
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("linkSso", () => {
it("throws an error when identifier is null", async () => {
await expect(sut.linkSso(null as unknown as string)).rejects.toThrow(
"SSO identifier is required",
);
});
it("throws an error when identifier is empty", async () => {
await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required");
});
it("calls preValidateSso with the provided identifier", async () => {
await sut.linkSso("org123");
expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123");
});
it("generates a password for code verifier", async () => {
await sut.linkSso("org123");
expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
});
});
it("sets the code verifier in the ssoLoginService", async () => {
await sut.linkSso("org123");
expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword");
});
it("generates a state and sets it in the ssoLoginService", async () => {
await sut.linkSso("org123");
const expectedState =
"mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123";
expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState);
});
it("gets the SSO user identifier from the API", async () => {
await sut.linkSso("org123");
expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled();
});
it("launches the authorize URL with the correct parameters", async () => {
await sut.linkSso("org123");
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
expect.stringContaining("https://identity.bitwarden.com/connect/authorize"),
{ sameWindow: true },
);
const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0];
expect(launchUriArg).toContain("client_id=web");
expect(launchUriArg).toContain(
"redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html",
);
expect(launchUriArg).toContain("response_type=code");
expect(launchUriArg).toContain("code_challenge=mockCodeChallenge");
expect(launchUriArg).toContain("ssoToken=mockSsoToken");
expect(launchUriArg).toContain("user_identifier=mockUserIdentifier");
});
});
});

View File

@@ -0,0 +1,91 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
/**
* Provides a service for linking SSO.
*/
export class LinkSsoService {
constructor(
private ssoLoginService: SsoLoginServiceAbstraction,
private apiService: ApiService,
private cryptoFunctionService: CryptoFunctionService,
private environmentService: EnvironmentService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) {}
/**
* Links SSO to an organization.
* Ported from the SsoComponent
* @param identifier The identifier of the organization to link to.
*/
async linkSso(identifier: string) {
if (identifier == null || identifier === "") {
throw new Error("SSO identifier is required");
}
const redirectUri = window.location.origin + "/sso-connector.html";
const clientId = "web";
const returnUri = "/settings/organizations";
const response = await this.apiService.preValidateSso(identifier);
const passwordOptions: PasswordGeneratorOptions = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
};
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
state += `_returnUri='${returnUri}'`;
state += `_identifier=${identifier}`;
// Save state
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
clientId +
"&redirect_uri=" +
encodeURIComponent(redirectUri) +
"&" +
"response_type=code&scope=api offline_access&" +
"state=" +
state +
"&code_challenge=" +
codeChallenge +
"&" +
"code_challenge_method=S256&response_mode=query&" +
"domain_hint=" +
encodeURIComponent(identifier) +
"&ssoToken=" +
encodeURIComponent(response.token);
const userIdentifier = await this.apiService.getSsoUserIdentifier();
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
}
}

View File

@@ -8,11 +8,15 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
// FIXME: remove `src` and fix import
@@ -38,6 +42,8 @@ describe("WebLoginComponentService", () => {
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
acceptOrganizationInviteService = mock<AcceptOrganizationInviteService>();
@@ -50,6 +56,7 @@ describe("WebLoginComponentService", () => {
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
ssoLoginService = mock<SsoLoginServiceAbstraction>();
accountService = mockAccountServiceWith(mockUserId);
TestBed.configureTestingModule({
providers: [
@@ -65,6 +72,7 @@ describe("WebLoginComponentService", () => {
{ provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
{ provide: AccountService, useValue: accountService },
],
});
service = TestBed.inject(WebLoginComponentService);

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import {
DefaultLoginComponentService,
@@ -12,8 +12,10 @@ import {
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -39,6 +41,7 @@ export class WebLoginComponentService
platformUtilsService: PlatformUtilsService,
ssoLoginService: SsoLoginServiceAbstraction,
private router: Router,
private accountService: AccountService,
) {
super(
cryptoFunctionService,
@@ -93,7 +96,10 @@ export class WebLoginComponentService
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
const enforcedPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(policies),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
),
);
return {

View File

@@ -10,9 +10,12 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
@@ -30,6 +33,8 @@ describe("WebRegistrationFinishService", () => {
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
keyService = mock<KeyService>();
@@ -38,6 +43,7 @@ describe("WebRegistrationFinishService", () => {
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
accountService = mockAccountServiceWith(mockUserId);
service = new WebRegistrationFinishService(
keyService,
@@ -46,6 +52,7 @@ describe("WebRegistrationFinishService", () => {
policyApiService,
logService,
policyService,
accountService,
);
});
@@ -180,11 +187,11 @@ describe("WebRegistrationFinishService", () => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
password: "password",
newPassword: "newPassword",
};
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -232,7 +239,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
@@ -270,7 +277,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
@@ -313,7 +320,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
@@ -358,7 +365,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
@@ -405,7 +412,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {

View File

@@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -30,6 +31,7 @@ export class WebRegistrationFinishService
private policyApiService: PolicyApiServiceAbstraction,
private logService: LogService,
private policyService: PolicyService,
private accountService: AccountService,
) {
super(keyService, accountApiService);
}
@@ -68,7 +70,7 @@ export class WebRegistrationFinishService
}
const masterPasswordPolicyOpts: MasterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(policies),
this.policyService.masterPasswordPolicyOptions$(null, policies),
);
return masterPasswordPolicyOpts;

View File

@@ -42,3 +42,7 @@ export class ViewTypeEmergencyAccess {
keyEncrypted: string;
ciphers: CipherResponse[] = [];
}
export class GranteeEmergencyAccessWithPublicKey extends GranteeEmergencyAccess {
publicKey: Uint8Array;
}

View File

@@ -11,6 +11,7 @@ import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.resp
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -41,6 +42,9 @@ describe("EmergencyAccessService", () => {
let emergencyAccessService: EmergencyAccessService;
let configService: ConfigService;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
beforeAll(() => {
emergencyAccessApiService = mock<EmergencyAccessApiService>();
apiService = mock<ApiService>();
@@ -226,10 +230,6 @@ describe("EmergencyAccessService", () => {
});
describe("getRotatedData", () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
@@ -250,7 +250,7 @@ describe("EmergencyAccessService", () => {
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
publicKey: "mockPublicKey",
publicKey: Utils.fromUtf8ToB64("trustedPublicKey"),
} as UserKeyResponse);
encryptService.rsaEncrypt.mockImplementation((plainValue, publicKey) => {
@@ -262,17 +262,32 @@ describe("EmergencyAccessService", () => {
it("Only returns emergency accesses with allowed statuses", async () => {
const result = await emergencyAccessService.getRotatedData(
mockOriginalUserKey,
mockNewUserKey,
mockTrustedPublicKeys,
"mockUserId" as UserId,
);
expect(result).toHaveLength(allowedStatuses.length);
});
it("Throws if emergency access public key is not trusted", async () => {
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
publicKey: Utils.fromUtf8ToB64("untrustedPublicKey"),
} as UserKeyResponse);
await expect(
emergencyAccessService.getRotatedData(
mockNewUserKey,
mockTrustedPublicKeys,
"mockUserId" as UserId,
),
).rejects.toThrow("Public key for user is not trusted.");
});
it("throws if new user key is null", async () => {
await expect(
emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
emergencyAccessService.getRotatedData(null, mockTrustedPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});

View File

@@ -22,14 +22,18 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
UserKeyRotationDataProvider,
KeyService,
KdfType,
UserKeyRotationKeyRecoveryProvider,
} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
import { GranteeEmergencyAccess, GrantorEmergencyAccess } from "../models/emergency-access";
import {
GranteeEmergencyAccess,
GranteeEmergencyAccessWithPublicKey,
GrantorEmergencyAccess,
} from "../models/emergency-access";
import { EmergencyAccessAcceptRequest } from "../request/emergency-access-accept.request";
import { EmergencyAccessConfirmRequest } from "../request/emergency-access-confirm.request";
import { EmergencyAccessInviteRequest } from "../request/emergency-access-invite.request";
@@ -38,12 +42,17 @@ import {
EmergencyAccessUpdateRequest,
EmergencyAccessWithIdRequest,
} from "../request/emergency-access-update.request";
import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService
implements UserKeyRotationDataProvider<EmergencyAccessWithIdRequest>
implements
UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
>
{
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
@@ -301,30 +310,12 @@ export class EmergencyAccessService
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
}
/**
* Returns existing emergency access keys re-encrypted with new user key.
* Intended for grantor.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if newUserKey is nullish
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
*/
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<EmergencyAccessWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const requests: EmergencyAccessWithIdRequest[] = [];
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {
const existingEmergencyAccess =
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
if (!existingEmergencyAccess || existingEmergencyAccess.data.length === 0) {
return requests;
return [];
}
// Any Invited or Accepted requests won't have the key yet, so we don't need to update them
@@ -337,13 +328,73 @@ export class EmergencyAccessService
allowedStatuses.has(d.status),
);
for (const details of filteredAccesses) {
// Get public key of grantee
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
return filteredAccesses;
}
async getPublicKeys(): Promise<GranteeEmergencyAccessWithPublicKey[]> {
const emergencyAccessData = await this.getEmergencyAccessData();
const emergencyAccessDataWithPublicKeys = await Promise.all(
emergencyAccessData.map(async (details) => {
const grantee = new GranteeEmergencyAccessWithPublicKey();
grantee.id = details.id;
grantee.granteeId = details.granteeId;
grantee.name = details.name;
grantee.email = details.email;
grantee.type = details.type;
grantee.status = details.status;
grantee.waitTimeDays = details.waitTimeDays;
grantee.creationDate = details.creationDate;
grantee.avatarColor = details.avatarColor;
grantee.publicKey = Utils.fromB64ToArray(
(await this.apiService.getUserPublicKey(details.granteeId)).publicKey,
);
return grantee;
}),
);
return emergencyAccessDataWithPublicKeys;
}
/**
* Returns existing emergency access keys re-encrypted with new user key.
* Intended for grantor.
* @param newUserKey the new user key
* @param trustedPublicKeys the public keys of the emergency access grantors. These *must* be trusted somehow, and MUST NOT be passed in untrusted
* @param userId the user id
* @throws Error if newUserKey is nullish
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
*/
async getRotatedData(
newUserKey: UserKey,
trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise<EmergencyAccessWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const requests: EmergencyAccessWithIdRequest[] = [];
this.logService.info(
"Starting emergency access rotation, with trusted keys: ",
trustedPublicKeys,
);
const allDetails = await this.getPublicKeys();
for (const details of allDetails) {
if (
trustedPublicKeys.find(
(pk) => Utils.fromBufferToHex(pk) === Utils.fromBufferToHex(details.publicKey),
) == null
) {
this.logService.info(
`Public key for user ${details.granteeId} is not trusted, skipping rotation.`,
);
throw new Error("Public key for user is not trusted.");
}
// Encrypt new user key with public key
const encryptedKey = await this.encryptKey(newUserKey, publicKey);
const encryptedKey = await this.encryptKey(newUserKey, details.publicKey);
const updateRequest = new EmergencyAccessWithIdRequest();
updateRequest.id = details.id;

View File

@@ -1,23 +0,0 @@
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input
bitInput
appAutofocus
inputmode="email"
appInputVerbatim="false"
type="email"
formControlName="email"
/>
<bit-hint>{{ "enterEmailToGetHint" | i18n }}</bit-hint>
</bit-form-field>
<hr />
<div class="tw-flex tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
{{ "submit" | i18n }}
</button>
<a bitButton buttonType="secondary" routerLink="/login" [block]="true">
{{ "cancel" | i18n }}
</a>
</div>
</form>

View File

@@ -1,64 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { HintComponent as BaseHintComponent } from "@bitwarden/angular/auth/components/hint.component";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
@Component({
selector: "app-hint",
templateUrl: "hint.component.html",
})
export class HintComponent extends BaseHintComponent implements OnInit {
formGroup = this.formBuilder.group({
email: ["", [Validators.email, Validators.required]],
});
get emailFormControl() {
return this.formGroup.controls.email;
}
constructor(
router: Router,
i18nService: I18nService,
apiService: ApiService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
loginEmailService: LoginEmailServiceAbstraction,
private formBuilder: FormBuilder,
protected toastService: ToastService,
) {
super(
router,
i18nService,
apiService,
platformUtilsService,
logService,
loginEmailService,
toastService,
);
}
async ngOnInit(): Promise<void> {
await super.ngOnInit();
this.emailFormControl.setValue(this.email);
}
// Wrapper method to call super.submit() since properties (e.g., submit) cannot use super directly
// This is because properties are assigned per type and generally don't have access to the prototype
async superSubmit() {
await super.submit();
}
submit = async () => {
this.email = this.emailFormControl.value;
await this.superSubmit();
};
}

View File

@@ -1,105 +0,0 @@
<div class="tw-container tw-mx-auto">
<div
class="tw-mx-auto tw-mt-5 tw-flex tw-max-w-lg tw-flex-col tw-items-center tw-justify-center tw-p-8"
>
<div class="tw-mb-6">
<img class="logo logo-themed" alt="Bitwarden" />
</div>
<ng-container *ngIf="loading">
<p class="text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</p>
</ng-container>
<div
*ngIf="!loading"
class="tw-w-full tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<ng-container *ngIf="data.state == State.ExistingUserUntrustedDevice">
<h2 bitTypography="h2" class="tw-mb-6">{{ "loginInitiated" | i18n }}</h2>
<p bitTypography="body1" class="tw-mb-6">
{{ "deviceApprovalRequired" | i18n }}
</p>
<form [formGroup]="rememberDeviceForm">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="rememberDevice" />
<bit-label>{{ "rememberThisDevice" | i18n }} </bit-label>
<bit-hint bitTypography="body2">{{ "uncheckIfPublicDevice" | i18n }}</bit-hint>
</bit-form-control>
</form>
<div class="tw-mb-6 tw-flex tw-flex-col tw-space-y-3">
<button
*ngIf="data.showApproveFromOtherDeviceBtn"
(click)="approveFromOtherDevice()"
bitButton
type="button"
buttonType="primary"
block
>
{{ "approveFromYourOtherDevice" | i18n }}
</button>
<button
*ngIf="data.showReqAdminApprovalBtn"
(click)="requestAdminApproval()"
bitButton
type="button"
buttonType="secondary"
>
{{ "requestAdminApproval" | i18n }}
</button>
<button
*ngIf="data.showApproveWithMasterPasswordBtn"
(click)="approveWithMasterPassword()"
bitButton
type="button"
buttonType="secondary"
block
>
{{ "approveWithMasterPassword" | i18n }}
</button>
</div>
</ng-container>
<ng-container *ngIf="data.state == State.NewUser">
<h2 bitTypography="h2" class="tw-mb-6">{{ "loggedInExclamation" | i18n }}</h2>
<form [formGroup]="rememberDeviceForm">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="rememberDevice" />
<bit-label>{{ "rememberThisDevice" | i18n }} </bit-label>
<bit-hint bitTypography="body2">{{ "uncheckIfPublicDevice" | i18n }}</bit-hint>
</bit-form-control>
</form>
<button
bitButton
type="button"
buttonType="primary"
block
class="tw-mb-6"
[bitAction]="createUserAction"
>
{{ "continue" | i18n }}
</button>
</ng-container>
<hr class="tw-mb-6 tw-mt-0" />
<div class="tw-m-0 tw-text-sm">
<p class="tw-mb-1">{{ "loggingInAs" | i18n }} {{ data.userEmail }}</p>
<a [routerLink]="[]" (click)="logOut()">{{ "notYou" | i18n }}</a>
</div>
</div>
</div>
</div>

View File

@@ -1,34 +0,0 @@
import { Component, inject } from "@angular/core";
import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component";
import { RouterService } from "../../../core";
import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service";
@Component({
selector: "web-login-decryption-options",
templateUrl: "login-decryption-options-v1.component.html",
})
export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 {
protected routerService = inject(RouterService);
protected acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
override async createUser(): Promise<void> {
try {
await super.createUser();
// Invites from TDE orgs go through here, but the invite is
// accepted while being enrolled in admin recovery. So we need to clear
// the redirect and stored org invite.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
await this.router.navigate(["/vault"]);
} catch (error) {
this.validationService.showError(error);
}
}
createUserAction = async (): Promise<void> => {
return this.createUser();
};
}

View File

@@ -1,129 +0,0 @@
<form
[bitSubmit]="submitForm.bind(null, false)"
[appApiAction]="formPromise"
[formGroup]="formGroup"
>
<ng-container *ngIf="!validatedEmail">
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input bitInput type="email" formControlName="email" appAutofocus />
</bit-form-field>
</div>
<div class="tw-mb-3 tw-flex tw-items-start">
<bit-form-control class="tw-mb-0">
<input type="checkbox" bitCheckbox formControlName="rememberEmail" />
<bit-label>{{ "rememberEmail" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-mb-3">
<button
bitButton
type="submit"
buttonType="primary"
class="tw-w-full"
(click)="validateEmail()"
>
<span> {{ "continue" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3 tw-flex tw-flex-col tw-items-center tw-justify-center">
<p class="tw-mb-3">{{ "or" | i18n }}</p>
<a
bitLink
block
linkType="primary"
routerLink="/login-with-passkey"
(mousedown)="$event.preventDefault()"
>
<span><i class="bwi bwi-passkey"></i> {{ "logInWithPasskey" | i18n }}</span>
</a>
</div>
<hr />
<p class="tw-m-0 tw-text-sm">
{{ "newAroundHere" | i18n }}
<!-- Two notes:
(1) We check the value and validity of email so we don't send an invalid email to autofill
on load of register for both enter and mouse based navigation
(2) We use mousedown to trigger navigation so that the onBlur form validation does not fire
and move the create account link down the page on click which causes the user to miss actually
clicking on the link. Mousedown fires before onBlur.
-->
<a
bitLink
routerLink="/signup"
[queryParams]="emailFormControl.valid ? { email: emailFormControl.value } : {}"
(mousedown)="goToRegister()"
>
{{ "createAccount" | i18n }}
</a>
</p>
</ng-container>
<div [ngClass]="{ 'tw-hidden': !validatedEmail }">
<div class="tw-mb-6 tw-h-28">
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input type="password" bitInput #masterPasswordInput formControlName="masterPassword" />
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
</bit-form-field>
<a
bitLink
class="tw-mt-2"
routerLink="/hint"
(mousedown)="goToHint()"
(click)="saveEmailSettings()"
>{{ "getMasterPasswordHint" | i18n }}</a
>
</div>
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="tw-mb-3 tw-flex tw-space-x-4">
<button bitButton buttonType="primary" bitFormButton type="submit" [block]="true">
<span> {{ "loginWithMasterPassword" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3" *ngIf="showLoginWithDevice">
<button
bitButton
type="button"
[block]="true"
buttonType="secondary"
(click)="startAuthRequestLogin()"
>
<span> <i class="bwi bwi-mobile"></i> {{ "loginWithDevice" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3">
<a
routerLink="/sso"
[queryParams]="{ email: formGroup.value.email }"
(click)="saveEmailSettings()"
bitButton
buttonType="secondary"
class="tw-w-full"
>
<i class="bwi bwi-provider tw-mr-2"></i>
{{ "enterpriseSingleSignOn" | i18n }}
</a>
</div>
<hr />
<div class="tw-m-0 tw-text-sm">
<p class="tw-mb-1">{{ "loggingInAs" | i18n }} {{ loggedEmail }}</p>
<a bitLink [routerLink]="[]" (click)="toggleValidateEmail(false)">{{ "notYou" | i18n }}</a>
</div>
</div>
</form>

View File

@@ -1,225 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, NgZone, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { RouterService } from "../../core";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
@Component({
selector: "app-login",
templateUrl: "login-v1.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class LoginComponentV1 extends BaseLoginComponent implements OnInit {
showResetPasswordAutoEnrollWarning = false;
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
policies: Policy[];
constructor(
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
route: ActivatedRoute,
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
cryptoFunctionService: CryptoFunctionService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: InternalPolicyService,
logService: LogService,
ngZone: NgZone,
protected stateService: StateService,
private routerService: RouterService,
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
toastService: ToastService,
) {
super(
devicesApiService,
appIdService,
loginStrategyService,
router,
platformUtilsService,
i18nService,
stateService,
environmentService,
passwordGenerationService,
cryptoFunctionService,
logService,
ngZone,
formBuilder,
formValidationErrorService,
route,
loginEmailService,
ssoLoginService,
toastService,
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
}
submitForm = async (showToast = true) => {
return await this.submitFormHelper(showToast);
};
private async submitFormHelper(showToast: boolean) {
await super.submit(showToast);
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
// If there is a query parameter called 'org', set previousUrl to `/create-organization?org=paramValue`
if (qParams.org != null) {
const route = this.router.createUrlTree(["create-organization"], {
queryParams: { plan: qParams.org },
});
this.routerService.setPreviousUrl(route.toString());
}
/**
* If there is a query parameter called 'sponsorshipToken', that means they are coming
* from an email for sponsoring a families organization. If so, then set the prevousUrl
* to `/setup/families-for-enterprise?token=paramValue`
*/
if (qParams.sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { token: qParams.sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
await super.ngOnInit();
});
// If there's an existing org invite, use it to get the password policies
const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
}
async goAfterLogIn(userId: UserId) {
const masterPassword = this.formGroup.value.masterPassword;
// Check master password against policy
if (this.enforcedPasswordPolicyOptions != null) {
const strengthResult = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.formGroup.value.email,
);
const masterPasswordScore = strengthResult == null ? null : strengthResult.score;
// If invalid, save policies and require update
if (
!this.policyService.evaluateMasterPassword(
masterPasswordScore,
masterPassword,
this.enforcedPasswordPolicyOptions,
)
) {
const policiesData: { [id: string]: PolicyData } = {};
this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
await this.policyService.replace(policiesData, userId);
await this.router.navigate(["update-password"]);
return;
}
}
this.loginEmailService.clearValues();
await this.router.navigate([this.successRoute]);
}
async goToHint() {
await this.saveEmailSettings();
await this.router.navigateByUrl("/hint");
}
async goToRegister() {
if (this.emailFormControl.valid) {
await this.router.navigate(["/signup"], {
queryParams: { email: this.emailFormControl.value },
});
return;
}
await this.router.navigate(["/signup"]);
}
protected override async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
await this.router.navigate(["migrate-legacy-encryption"]);
return true;
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies == null) {
return;
}
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
this.policies,
invite.organizationId,
);
// Set to true if policy enabled and auto-enroll enabled
this.showResetPasswordAutoEnrollWarning =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}

View File

@@ -1,70 +0,0 @@
<div
class="tw-mx-auto tw-mt-5 tw-flex tw-max-w-lg tw-flex-col tw-items-center tw-justify-center tw-p-8"
>
<div>
<img class="logo logo-themed" alt="Bitwarden" />
<ng-container *ngIf="state == StateEnum.StandardAuthRequest">
<p class="tw-mx-4 tw-mb-4 tw-mt-3 tw-text-center tw-text-xl">
{{ "loginOrCreateNewAccount" | i18n }}
</p>
<div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "logInRequestSent" | i18n }}</h2>
<p class="tw-mb-6">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<div class="tw-mb-6">
<h4 class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</h4>
<p>
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<div class="tw-my-10" *ngIf="showResendNotification">
<a [routerLink]="[]" disabled="true" (click)="startAuthRequestLogin()">{{
"resendNotification" | i18n
}}</a>
</div>
<hr />
<div class="tw-mt-3">
{{ "loginWithDeviceEnabledNote" | i18n }}
<a routerLink="/login">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
</div>
</ng-container>
<ng-container *ngIf="state == StateEnum.AdminAuthRequest">
<div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "adminApprovalRequested" | i18n }}</h2>
<div>
<p class="tw-mb-6">{{ "adminApprovalRequestSentToAdmins" | i18n }}</p>
<p class="tw-mb-6">{{ "youWillBeNotifiedOnceApproved" | i18n }}</p>
</div>
<div class="tw-mb-6">
<h4 class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</h4>
<p>
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<hr />
<div class="tw-mt-3">
{{ "troubleLoggingIn" | i18n }}
<a routerLink="/login-initiated">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
</div>
</ng-container>
</div>
</div>

View File

@@ -1,9 +0,0 @@
import { Component } from "@angular/core";
import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request-v1.component.html",
})
export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {}

View File

@@ -4,24 +4,11 @@ import { CheckboxModule } from "@bitwarden/components";
import { SharedModule } from "../../../app/shared";
import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "./login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component";
import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component";
@NgModule({
imports: [SharedModule, CheckboxModule],
declarations: [
LoginComponentV1,
LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponentV1,
LoginViaWebAuthnComponent,
],
exports: [
LoginComponentV1,
LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponentV1,
LoginViaWebAuthnComponent,
],
declarations: [LoginViaWebAuthnComponent],
exports: [LoginViaWebAuthnComponent],
})
export class LoginModule {}

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -11,14 +12,19 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
import { I18nService } from "../../core/i18n.service";
import {
@@ -41,6 +47,8 @@ describe("AcceptOrganizationInviteService", () => {
let i18nService: MockProxy<I18nService>;
let globalStateProvider: FakeGlobalStateProvider;
let globalState: FakeGlobalState<OrganizationInvite>;
let dialogService: MockProxy<DialogService>;
let accountService: MockProxy<AccountService>;
beforeEach(() => {
apiService = mock();
@@ -55,6 +63,8 @@ describe("AcceptOrganizationInviteService", () => {
i18nService = mock();
globalStateProvider = new FakeGlobalStateProvider();
globalState = globalStateProvider.getFake(ORGANIZATION_INVITE);
dialogService = mock();
accountService = mock();
sut = new AcceptOrganizationInviteService(
apiService,
@@ -68,6 +78,8 @@ describe("AcceptOrganizationInviteService", () => {
organizationUserApiService,
i18nService,
globalStateProvider,
dialogService,
accountService,
);
});
@@ -142,7 +154,7 @@ describe("AcceptOrganizationInviteService", () => {
expect(authService.logOut).not.toHaveBeenCalled();
});
it("accepts the invitation request when the org has a master password policy, but the user has already passed it", async () => {
it("accepts the invitation request when the org has a master password policy, but the user has already passed it and autoenroll is not enabled", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
@@ -167,6 +179,53 @@ describe("AcceptOrganizationInviteService", () => {
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
it("accepts the invitation request and enrolls when autoenroll is enabled", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
type: PolicyType.MasterPassword,
enabled: true,
} as Policy,
]);
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "privateKey",
publicKey: "publicKey",
}),
);
accountService.activeAccount$ = new BehaviorSubject({ id: "activeUserId" }) as any;
keyService.userKey$.mockReturnValue(new BehaviorSubject({ key: "userKey" } as any));
encryptService.rsaEncrypt.mockResolvedValue({
encryptedString: "encryptedString",
} as EncString);
jest.mock("../../admin-console/organizations/manage/organization-trust.component");
OrganizationTrustComponent.open = jest.fn().mockReturnValue({
closed: new BehaviorSubject(true),
});
await globalState.update(() => invite);
policyService.getResetPasswordPolicyOptions.mockReturnValue([
{
autoEnrollEnabled: true,
} as ResetPasswordPolicyOptions,
true,
]);
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(true);
expect(OrganizationTrustComponent.open).toHaveBeenCalled();
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(
"userKey",
Utils.fromB64ToArray("publicKey"),
);
expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
});
});

View File

@@ -15,6 +15,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -27,8 +28,11 @@ import {
ORGANIZATION_INVITE_DISK,
} from "@bitwarden/common/platform/state";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
import { OrganizationInvite } from "./organization-invite";
// We're storing the organization invite for 2 reasons:
@@ -63,6 +67,8 @@ export class AcceptOrganizationInviteService {
private readonly organizationUserApiService: OrganizationUserApiService,
private readonly i18nService: I18nService,
private readonly globalStateProvider: GlobalStateProvider,
private readonly dialogService: DialogService,
private readonly accountService: AccountService,
) {
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
}
@@ -183,9 +189,19 @@ export class AcceptOrganizationInviteService {
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
const dialogRef = OrganizationTrustComponent.open(this.dialogService, {
name: invite.organizationName,
orgId: invite.organizationId,
publicKey,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== true) {
throw new Error("Organization not trusted, aborting user key rotation");
}
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
// RSA Encrypt user's encKey.key with organization public key
const userKey = await this.keyService.getUserKey();
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
// Add reset password key to accept request

View File

@@ -9,7 +9,6 @@ import {
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { TwoFactorRecoveryRequest } from "@bitwarden/common/auth/models/request/two-factor-recovery.request";
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";
@@ -85,15 +84,14 @@ describe("RecoverTwoFactorComponent", () => {
describe("handleRecoveryLogin", () => {
it("should log in successfully and navigate to the two-factor settings page", async () => {
// Arrange
const request = new TwoFactorRecoveryRequest();
request.recoveryCode = "testRecoveryCode";
request.email = "test@example.com";
const email = "test@example.com";
const recoveryCode = "testRecoveryCode";
const authResult = new AuthResult();
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
// Act
await component["handleRecoveryLogin"](request);
await component["loginWithRecoveryCode"](email, recoveryCode);
// Assert
expect(mockLoginStrategyService.logIn).toHaveBeenCalledWith(
@@ -112,15 +110,14 @@ describe("RecoverTwoFactorComponent", () => {
it("should handle login errors and redirect to login page", async () => {
// Arrange
const request = new TwoFactorRecoveryRequest();
request.recoveryCode = "testRecoveryCode";
request.email = "test@example.com";
const email = "test@example.com";
const recoveryCode = "testRecoveryCode";
const error = new Error("Login failed");
mockLoginStrategyService.logIn.mockRejectedValue(error);
// Act
await component["handleRecoveryLogin"](request);
await component["loginWithRecoveryCode"](email, recoveryCode);
// Assert
expect(mockLogService.error).toHaveBeenCalledWith(
@@ -128,7 +125,7 @@ describe("RecoverTwoFactorComponent", () => {
error.message,
);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/login"], {
queryParams: { email: request.email },
queryParams: { email: email },
});
});
});

View File

@@ -7,17 +7,11 @@ import {
PasswordLoginCredentials,
LoginSuccessHandlerService,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorRecoveryRequest } from "@bitwarden/common/auth/models/request/two-factor-recovery.request";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
@Component({
@@ -36,32 +30,18 @@ export class RecoverTwoFactorComponent implements OnInit {
*/
recoveryCodeMessage = "";
/**
* Whether the recovery code login feature flag is enabled
*/
private recoveryCodeLoginFeatureFlagEnabled = false;
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private keyService: KeyService,
private loginStrategyService: LoginStrategyServiceAbstraction,
private toastService: ToastService,
private configService: ConfigService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private logService: LogService,
private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService,
) {}
async ngOnInit() {
this.recoveryCodeLoginFeatureFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.RecoveryCodeLogin,
);
this.recoveryCodeMessage = this.recoveryCodeLoginFeatureFlagEnabled
? this.i18nService.t("logInBelowUsingYourSingleUseRecoveryCode")
: this.i18nService.t("recoverAccountTwoStepDesc");
this.recoveryCodeMessage = this.i18nService.t("logInBelowUsingYourSingleUseRecoveryCode");
}
get email(): string {
@@ -85,38 +65,25 @@ export class RecoverTwoFactorComponent implements OnInit {
return;
}
const request = new TwoFactorRecoveryRequest();
request.recoveryCode = this.recoveryCode.replace(/\s/g, "").toLowerCase();
request.email = this.email.trim().toLowerCase();
const key = await this.loginStrategyService.makePreloginKey(this.masterPassword, request.email);
request.masterPasswordHash = await this.keyService.hashMasterKey(this.masterPassword, key);
const email = this.email.trim().toLowerCase();
const recoveryCode = this.recoveryCode.replace(/\s/g, "").toLowerCase();
if (this.recoveryCodeLoginFeatureFlagEnabled) {
await this.handleRecoveryLogin(request);
} else {
await this.apiService.postTwoFactorRecover(request);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("twoStepRecoverDisabled"),
});
await this.router.navigate(["/"]);
}
await this.loginWithRecoveryCode(email, recoveryCode);
};
/**
* Handles the login process after a successful account recovery.
*/
private async handleRecoveryLogin(request: TwoFactorRecoveryRequest) {
private async loginWithRecoveryCode(email: string, recoveryCode: string) {
// Build two-factor request to pass into PasswordLoginCredentials request using the 2FA recovery code and RecoveryCode type
const twoFactorRequest: TokenTwoFactorRequest = {
provider: TwoFactorProviderType.RecoveryCode,
token: request.recoveryCode,
token: recoveryCode,
remember: false,
};
const credentials = new PasswordLoginCredentials(
request.email,
email,
this.masterPassword,
"",
twoFactorRequest,
@@ -148,7 +115,7 @@ export class RecoverTwoFactorComponent implements OnInit {
} catch (error) {
// If login errors, redirect to login page per product. Don't show error
this.logService.error("Error logging in automatically: ", (error as Error).message);
await this.router.navigate(["/login"], { queryParams: { email: request.email } });
await this.router.navigate(["/login"], { queryParams: { email: email } });
}
}
}

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import {
Component,
ElementRef,
@@ -17,7 +16,13 @@ import { ProfileResponse } from "@bitwarden/common/models/response/profile.respo
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 { DialogService, ToastService } from "@bitwarden/components";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
type ChangeAvatarDialogData = {
profile: ProfileResponse;

View File

@@ -39,10 +39,12 @@
</div>
</ng-container>
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ (tokenSent ? "changeEmail" : "continue") | i18n }}
</button>
<button type="button" bitButton *ngIf="tokenSent" (click)="reset()">
{{ "cancel" | i18n }}
</button>
<div class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ (tokenSent ? "changeEmail" : "continue") | i18n }}
</button>
<button type="button" bitButton *ngIf="tokenSent" (click)="reset()">
{{ "cancel" | i18n }}
</button>
</div>
</form>

View File

@@ -0,0 +1,197 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import mock, { MockProxy } from "jest-mock-extended/lib/Mock";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangeEmailComponent } from "@bitwarden/web-vault/app/auth/settings/account/change-email.component";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
describe("ChangeEmailComponent", () => {
let component: ChangeEmailComponent;
let fixture: ComponentFixture<ChangeEmailComponent>;
let apiService: MockProxy<ApiService>;
let accountService: FakeAccountService;
let keyService: MockProxy<KeyService>;
let kdfConfigService: MockProxy<KdfConfigService>;
beforeEach(async () => {
apiService = mock<ApiService>();
keyService = mock<KeyService>();
kdfConfigService = mock<KdfConfigService>();
accountService = mockAccountServiceWith("UserId" as UserId);
await TestBed.configureTestingModule({
declarations: [ChangeEmailComponent],
imports: [ReactiveFormsModule, SharedModule],
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: ApiService, useValue: apiService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: KeyService, useValue: keyService },
{ provide: MessagingService, useValue: mock<MessagingService>() },
{ provide: KdfConfigService, useValue: kdfConfigService },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: FormBuilder, useClass: FormBuilder },
],
}).compileComponents();
fixture = TestBed.createComponent(ChangeEmailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("creates component", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
beforeEach(() => {
apiService.getTwoFactorProviders.mockResolvedValue({
data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse],
} as ListResponse<TwoFactorProviderResponse>);
});
it("initializes userId", async () => {
await component.ngOnInit();
expect(component.userId).toBe("UserId");
});
it("errors if there is no active user", async () => {
// clear active account
await firstValueFrom(accountService.activeAccount$);
accountService.activeAccountSubject.next(null);
await expect(() => component.ngOnInit()).rejects.toThrow("Null or undefined account");
});
it("initializes showTwoFactorEmailWarning", async () => {
await component.ngOnInit();
expect(component.showTwoFactorEmailWarning).toBe(true);
});
});
describe("submit", () => {
beforeEach(() => {
component.formGroup.controls.step1.setValue({
masterPassword: "password",
newEmail: "test@example.com",
});
keyService.getOrDeriveMasterKey
.calledWith("password", "UserId")
.mockResolvedValue("getOrDeriveMasterKey" as any);
keyService.hashMasterKey
.calledWith("password", "getOrDeriveMasterKey" as any)
.mockResolvedValue("existingHash");
});
it("throws if userId is null on submit", async () => {
component.userId = undefined;
await expect(component.submit()).rejects.toThrow("Can't find user");
});
describe("step 1", () => {
it("does not submit if step 1 is invalid", async () => {
component.formGroup.controls.step1.setValue({
masterPassword: "",
newEmail: "",
});
await component.submit();
expect(apiService.postEmailToken).not.toHaveBeenCalled();
});
it("sends email token in step 1 if tokenSent is false", async () => {
await component.submit();
expect(apiService.postEmailToken).toHaveBeenCalledWith({
newEmail: "test@example.com",
masterPasswordHash: "existingHash",
});
// should activate step 2
expect(component.tokenSent).toBe(true);
expect(component.formGroup.controls.step1.disabled).toBe(true);
expect(component.formGroup.controls.token.enabled).toBe(true);
});
});
describe("step 2", () => {
beforeEach(() => {
component.tokenSent = true;
component.formGroup.controls.step1.disable();
component.formGroup.controls.token.enable();
component.formGroup.controls.token.setValue("token");
kdfConfigService.getKdfConfig$
.calledWith("UserId" as any)
.mockReturnValue(of("kdfConfig" as any));
keyService.userKey$.calledWith("UserId" as any).mockReturnValue(of("userKey" as any));
keyService.makeMasterKey
.calledWith("password", "test@example.com", "kdfConfig" as any)
.mockResolvedValue("newMasterKey" as any);
keyService.hashMasterKey
.calledWith("password", "newMasterKey" as any)
.mockResolvedValue("newMasterKeyHash");
// Important: make sure this is called with new master key, not existing
keyService.encryptUserKeyWithMasterKey
.calledWith("newMasterKey" as any, "userKey" as any)
.mockResolvedValue(["userKey" as any, { encryptedString: "newEncryptedUserKey" } as any]);
});
it("does not post email if token is missing on submit", async () => {
component.formGroup.controls.token.setValue("");
await component.submit();
expect(apiService.postEmail).not.toHaveBeenCalled();
});
it("throws if kdfConfig is missing on submit", async () => {
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
await expect(component.submit()).rejects.toThrow("Missing kdf config");
});
it("throws if userKey can't be found", async () => {
keyService.userKey$.mockReturnValue(of(null));
await expect(component.submit()).rejects.toThrow("Can't find UserKey");
});
it("throws if encryptedUserKey is missing", async () => {
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(["userKey" as any, null as any]);
await expect(component.submit()).rejects.toThrow("Missing Encrypted User Key");
});
it("submits if step 2 is valid", async () => {
await component.submit();
// validate that hashes are correct
expect(apiService.postEmail).toHaveBeenCalledWith({
masterPasswordHash: "existingHash",
newMasterPasswordHash: "newMasterKeyHash",
token: "token",
newEmail: "test@example.com",
key: "newEncryptedUserKey",
});
});
});
});
});

View File

@@ -1,17 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
import { getUserId } from "@bitwarden/common/auth/services/account.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";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -22,8 +21,9 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
export class ChangeEmailComponent implements OnInit {
tokenSent = false;
showTwoFactorEmailWarning = false;
userId: UserId | undefined;
protected formGroup = this.formBuilder.group({
formGroup = this.formBuilder.group({
step1: this.formBuilder.group({
masterPassword: ["", [Validators.required]],
newEmail: ["", [Validators.required, Validators.email]],
@@ -32,26 +32,30 @@ export class ChangeEmailComponent implements OnInit {
});
constructor(
private accountService: AccountService,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private keyService: KeyService,
private messagingService: MessagingService,
private logService: LogService,
private stateService: StateService,
private formBuilder: FormBuilder,
private kdfConfigService: KdfConfigService,
private toastService: ToastService,
) {}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const twoFactorProviders = await this.apiService.getTwoFactorProviders();
this.showTwoFactorEmailWarning = twoFactorProviders.data.some(
(p) => p.type === TwoFactorProviderType.Email && p.enabled,
);
}
protected submit = async () => {
submit = async () => {
if (this.userId == null) {
throw new Error("Can't find user");
}
// This form has multiple steps, so we need to mark all the groups as touched.
this.formGroup.controls.step1.markAllAsTouched();
@@ -65,37 +69,54 @@ export class ChangeEmailComponent implements OnInit {
}
const step1Value = this.formGroup.controls.step1.value;
const newEmail = step1Value.newEmail.trim().toLowerCase();
const newEmail = step1Value.newEmail?.trim().toLowerCase();
const masterPassword = step1Value.masterPassword;
if (newEmail == null || masterPassword == null) {
throw new Error("Missing email or password");
}
const existingHash = await this.keyService.hashMasterKey(
masterPassword,
await this.keyService.getOrDeriveMasterKey(masterPassword, this.userId),
);
if (!this.tokenSent) {
const request = new EmailTokenRequest();
request.newEmail = newEmail;
request.masterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
await this.keyService.getOrDeriveMasterKey(step1Value.masterPassword),
);
request.masterPasswordHash = existingHash;
await this.apiService.postEmailToken(request);
this.activateStep2();
} else {
const token = this.formGroup.value.token;
if (token == null) {
throw new Error("Missing token");
}
const request = new EmailRequest();
request.token = this.formGroup.value.token;
request.token = token;
request.newEmail = newEmail;
request.masterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
await this.keyService.getOrDeriveMasterKey(step1Value.masterPassword),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const newMasterKey = await this.keyService.makeMasterKey(
step1Value.masterPassword,
newEmail,
kdfConfig,
);
request.masterPasswordHash = existingHash;
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
if (kdfConfig == null) {
throw new Error("Missing kdf config");
}
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
masterPassword,
newMasterKey,
);
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey);
request.key = newUserKey[1].encryptedString;
const userKey = await firstValueFrom(this.keyService.userKey$(this.userId));
if (userKey == null) {
throw new Error("Can't find UserKey");
}
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
const encryptedUserKey = newUserKey[1]?.encryptedString;
if (encryptedUserKey == null) {
throw new Error("Missing Encrypted User Key");
}
request.key = encryptedUserKey;
await this.apiService.postEmail(request);
this.reset();

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
@@ -8,7 +7,7 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
@Component({
templateUrl: "delete-account-dialog.component.html",

View File

@@ -1,4 +1,3 @@
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
@@ -15,6 +14,7 @@ import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DialogRef,
AsyncActionsModule,
ButtonModule,
CalloutModule,

View File

@@ -121,7 +121,7 @@
[(ngModel)]="masterPasswordHint"
/>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
<button type="submit" buttonType="primary" bitButton [loading]="loading">
{{ "changeMasterPassword" | i18n }}
</button>
</form>

View File

@@ -5,14 +5,16 @@ import { Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -35,11 +37,13 @@ export class ChangePasswordComponent
extends BaseChangePasswordComponent
implements OnInit, OnDestroy
{
loading = false;
rotateUserKey = false;
currentMasterPassword: string;
masterPasswordHint: string;
checkForBreaches = true;
characterMinimumMessage = "";
userkeyRotationV2 = false;
constructor(
i18nService: I18nService,
@@ -50,15 +54,16 @@ export class ChangePasswordComponent
private auditService: AuditService,
private cipherService: CipherService,
private syncService: SyncService,
private apiService: ApiService,
private masterPasswordApiService: MasterPasswordApiService,
private router: Router,
dialogService: DialogService,
private userVerificationService: UserVerificationService,
private keyRotationService: UserKeyRotationService,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
toastService: ToastService,
private configService: ConfigService,
) {
super(
i18nService,
@@ -75,6 +80,8 @@ export class ChangePasswordComponent
}
async ngOnInit() {
this.userkeyRotationV2 = await this.configService.getFeatureFlag(FeatureFlag.UserKeyRotationV2);
if (!(await this.userVerificationService.hasMasterPassword())) {
// 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
@@ -137,6 +144,130 @@ export class ChangePasswordComponent
}
async submit() {
if (this.userkeyRotationV2) {
this.loading = true;
await this.submitNew();
this.loading = false;
} else {
await this.submitOld();
}
}
async submitNew() {
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return;
}
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("hintEqualsPassword"),
});
return;
}
this.leakedPassword = false;
if (this.checkForBreaches) {
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0;
}
if (!(await this.strongPassword())) {
return;
}
try {
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
this.currentMasterPassword,
this.masterPassword,
user,
this.masterPasswordHint,
);
} else {
await this.updatePassword(this.masterPassword);
}
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
}
}
// todo: move this to a service
// https://bitwarden.atlassian.net/browse/PM-17108
private async updatePassword(newMasterPassword: string) {
const currentMasterPassword = this.currentMasterPassword;
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))),
);
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
const currentMasterKey = await this.keyService.makeMasterKey(
currentMasterPassword,
email,
kdfConfig,
);
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = await this.keyService.hashMasterKey(
this.currentMasterPassword,
currentMasterKey,
);
request.masterPasswordHint = this.masterPasswordHint;
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
try {
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
async submitOld() {
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
@@ -213,14 +344,14 @@ export class ChangePasswordComponent
try {
if (this.rotateUserKey) {
this.formPromise = this.apiService.postPassword(request).then(async () => {
this.formPromise = this.masterPasswordApiService.postPassword(request).then(async () => {
// we need to save this for local masterkey verification during rotation
await this.masterPasswordService.setMasterKeyHash(newLocalKeyHash, userId as UserId);
await this.masterPasswordService.setMasterKey(newMasterKey, userId as UserId);
return this.updateKey();
});
} else {
this.formPromise = this.apiService.postPassword(request);
this.formPromise = this.masterPasswordApiService.postPassword(request);
}
await this.formPromise;
@@ -242,6 +373,6 @@ export class ChangePasswordComponent
private async updateKey() {
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(this.masterPassword, user);
}
}

View File

@@ -1,66 +0,0 @@
import { Component } from "@angular/core";
import { AttachmentsComponent as BaseAttachmentsComponent } from "@bitwarden/angular/vault/components/attachments.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@Component({
selector: "emergency-access-attachments",
templateUrl: "../../../../vault/individual-vault/attachments.component.html",
})
export class EmergencyAccessAttachmentsComponent extends BaseAttachmentsComponent {
viewOnly = true;
canAccessAttachments = true;
constructor(
cipherService: CipherService,
i18nService: I18nService,
keyService: KeyService,
encryptService: EncryptService,
stateService: StateService,
platformUtilsService: PlatformUtilsService,
apiService: ApiService,
logService: LogService,
fileDownloadService: FileDownloadService,
dialogService: DialogService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
toastService: ToastService,
) {
super(
cipherService,
i18nService,
keyService,
encryptService,
platformUtilsService,
apiService,
window,
logService,
stateService,
fileDownloadService,
dialogService,
billingAccountProfileStateService,
accountService,
toastService,
);
}
protected async init() {
// Do nothing since cipher is already decoded
}
protected showFixOldAttachments(attachment: AttachmentView) {
return false;
}
}

View File

@@ -25,13 +25,13 @@
<bit-label> {{ "dontAskFingerprintAgain" | i18n }}</bit-label>
</bit-form-control>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" bitButton bitFormButton>
<span>{{ "confirm" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,12 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService } from "@bitwarden/components";
import { DialogConfig, DialogRef, DIALOG_DATA, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
export enum EmergencyAccessConfirmDialogResult {

View File

@@ -1,13 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DialogConfig,
DialogRef,
DIALOG_DATA,
DialogService,
ToastService,
} from "@bitwarden/components";
import { EmergencyAccessService } from "../../emergency-access";
import { EmergencyAccessType } from "../../emergency-access/enums/emergency-access-type";

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