mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
* require userID for initAccount on key service * add unit test coverage * update consumer
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
// FIXME: Update this file to be type safe and remove this and next line
|
|
// @ts-strict-ignore
|
|
import { CommonModule } from "@angular/common";
|
|
import { Component, DestroyRef, OnInit } from "@angular/core";
|
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms";
|
|
import { Router } from "@angular/router";
|
|
import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs";
|
|
|
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|
import {
|
|
LoginEmailServiceAbstraction,
|
|
LogoutService,
|
|
UserDecryptionOptions,
|
|
UserDecryptionOptionsServiceAbstraction,
|
|
} from "@bitwarden/auth/common";
|
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
|
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
|
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
|
import { ClientType } from "@bitwarden/common/enums";
|
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
|
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
|
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";
|
|
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
|
import { UserId } from "@bitwarden/common/types/guid";
|
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import {
|
|
AnonLayoutWrapperDataService,
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
DialogService,
|
|
FormFieldModule,
|
|
ToastService,
|
|
TypographyModule,
|
|
} from "@bitwarden/components";
|
|
import { KeyService } from "@bitwarden/key-management";
|
|
|
|
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
|
|
|
|
// FIXME: update to use a const object instead of a typescript enum
|
|
// eslint-disable-next-line @bitwarden/platform/no-enums
|
|
enum State {
|
|
NewUser,
|
|
ExistingUserUntrustedDevice,
|
|
}
|
|
|
|
@Component({
|
|
templateUrl: "./login-decryption-options.component.html",
|
|
imports: [
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
CommonModule,
|
|
FormFieldModule,
|
|
JslibModule,
|
|
ReactiveFormsModule,
|
|
TypographyModule,
|
|
],
|
|
})
|
|
export class LoginDecryptionOptionsComponent implements OnInit {
|
|
private activeAccountId: UserId;
|
|
private clientType: ClientType;
|
|
private email: string;
|
|
|
|
protected loading = false;
|
|
protected state: State;
|
|
protected State = State;
|
|
|
|
protected formGroup = this.formBuilder.group({
|
|
rememberDevice: [true], // Remember device means for the user to trust the device
|
|
});
|
|
|
|
private get rememberDeviceControl(): FormControl<boolean> {
|
|
return this.formGroup.controls.rememberDevice;
|
|
}
|
|
|
|
// New User Properties
|
|
private newUserOrgId: string;
|
|
|
|
// Existing User Untrusted Device Properties
|
|
protected canApproveFromOtherDevice = false;
|
|
protected canRequestAdminApproval = false;
|
|
protected canApproveWithMasterPassword = false;
|
|
|
|
constructor(
|
|
private accountService: AccountService,
|
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
|
private apiService: ApiService,
|
|
private destroyRef: DestroyRef,
|
|
private deviceTrustService: DeviceTrustServiceAbstraction,
|
|
private dialogService: DialogService,
|
|
private formBuilder: FormBuilder,
|
|
private i18nService: I18nService,
|
|
private keyService: KeyService,
|
|
private loginDecryptionOptionsService: LoginDecryptionOptionsService,
|
|
private loginEmailService: LoginEmailServiceAbstraction,
|
|
private messagingService: MessagingService,
|
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
|
private passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private router: Router,
|
|
private ssoLoginService: SsoLoginServiceAbstraction,
|
|
private toastService: ToastService,
|
|
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
|
private validationService: ValidationService,
|
|
private logoutService: LogoutService,
|
|
) {
|
|
this.clientType = this.platformUtilsService.getClientType();
|
|
}
|
|
|
|
async ngOnInit() {
|
|
this.loading = true;
|
|
|
|
this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
|
|
|
this.email = await firstValueFrom(
|
|
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
|
);
|
|
|
|
if (!this.email) {
|
|
await this.handleMissingEmail();
|
|
return;
|
|
}
|
|
|
|
this.observeAndPersistRememberDeviceValueChanges();
|
|
await this.setRememberDeviceDefaultValueFromState();
|
|
|
|
try {
|
|
const userDecryptionOptions = await firstValueFrom(
|
|
this.userDecryptionOptionsService.userDecryptionOptions$,
|
|
);
|
|
|
|
if (
|
|
!userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval &&
|
|
!userDecryptionOptions?.hasMasterPassword
|
|
) {
|
|
/**
|
|
* We are dealing with a new account if both are true:
|
|
* - User does NOT have admin approval (i.e. has not enrolled in admin reset)
|
|
* - User does NOT have a master password
|
|
*/
|
|
await this.loadNewUserData();
|
|
} else {
|
|
this.loadExistingUserUntrustedDeviceData(userDecryptionOptions);
|
|
}
|
|
} catch (err) {
|
|
this.validationService.showError(err);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
private async handleMissingEmail() {
|
|
// TODO: PM-15174 - the solution for this bug will allow us to show the toast on app re-init after
|
|
// the user has been logged out and the process reload has occurred.
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: null,
|
|
message: this.i18nService.t("activeUserEmailNotFoundLoggingYouOut"),
|
|
});
|
|
|
|
await this.logoutService.logout(this.activeAccountId);
|
|
// navigate to root so redirect guard can properly route next active user or null user to correct page
|
|
await this.router.navigate(["/"]);
|
|
}
|
|
|
|
private observeAndPersistRememberDeviceValueChanges() {
|
|
this.rememberDeviceControl.valueChanges
|
|
.pipe(
|
|
takeUntilDestroyed(this.destroyRef),
|
|
switchMap((value) =>
|
|
defer(() => this.deviceTrustService.setShouldTrustDevice(this.activeAccountId, value)),
|
|
),
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
private async setRememberDeviceDefaultValueFromState() {
|
|
const rememberDeviceFromState = await this.deviceTrustService.getShouldTrustDevice(
|
|
this.activeAccountId,
|
|
);
|
|
|
|
const rememberDevice = rememberDeviceFromState ?? true;
|
|
|
|
this.rememberDeviceControl.setValue(rememberDevice);
|
|
}
|
|
|
|
private async loadNewUserData() {
|
|
this.state = State.NewUser;
|
|
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageTitle: {
|
|
key: "loggedInExclamation",
|
|
},
|
|
pageSubtitle: {
|
|
key: "rememberThisDeviceToMakeFutureLoginsSeamless",
|
|
},
|
|
});
|
|
|
|
const autoEnrollStatus$ = defer(() =>
|
|
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId),
|
|
).pipe(
|
|
switchMap((organizationIdentifier) => {
|
|
if (organizationIdentifier == undefined) {
|
|
return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired")));
|
|
}
|
|
|
|
return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier));
|
|
}),
|
|
catchError((err: unknown) => {
|
|
this.validationService.showError(err);
|
|
return of(undefined);
|
|
}),
|
|
);
|
|
|
|
const autoEnrollStatus = await firstValueFrom(autoEnrollStatus$);
|
|
|
|
this.newUserOrgId = autoEnrollStatus.id;
|
|
}
|
|
|
|
private loadExistingUserUntrustedDeviceData(userDecryptionOptions: UserDecryptionOptions) {
|
|
this.state = State.ExistingUserUntrustedDevice;
|
|
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageTitle: {
|
|
key: "deviceApprovalRequiredV2",
|
|
},
|
|
pageSubtitle: {
|
|
key: "selectAnApprovalOptionBelow",
|
|
},
|
|
});
|
|
|
|
this.canApproveFromOtherDevice =
|
|
userDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false;
|
|
this.canRequestAdminApproval =
|
|
userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false;
|
|
this.canApproveWithMasterPassword = userDecryptionOptions?.hasMasterPassword || false;
|
|
}
|
|
|
|
protected createUser = async () => {
|
|
if (this.state !== State.NewUser) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
|
|
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
|
|
await this.apiService.postAccountKeys(keysRequest);
|
|
|
|
this.toastService.showToast({
|
|
variant: "success",
|
|
title: null,
|
|
message: this.i18nService.t("accountSuccessfullyCreated"),
|
|
});
|
|
|
|
await this.passwordResetEnrollmentService.enroll(this.newUserOrgId);
|
|
|
|
if (this.formGroup.value.rememberDevice) {
|
|
await this.deviceTrustService.trustDevice(this.activeAccountId);
|
|
}
|
|
|
|
await this.loginDecryptionOptionsService.handleCreateUserSuccess();
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
this.messagingService.send("redrawMenu");
|
|
}
|
|
|
|
await this.handleCreateUserSuccessNavigation();
|
|
} catch (err) {
|
|
this.validationService.showError(err);
|
|
}
|
|
};
|
|
|
|
private async handleCreateUserSuccessNavigation() {
|
|
if (this.clientType === ClientType.Browser) {
|
|
await this.router.navigate(["/tabs/vault"]);
|
|
} else {
|
|
await this.router.navigate(["/vault"]);
|
|
}
|
|
}
|
|
|
|
protected async approveFromOtherDevice() {
|
|
await this.router.navigate(["/login-with-device"]);
|
|
}
|
|
|
|
protected async approveWithMasterPassword() {
|
|
await this.router.navigate(["/lock"], {
|
|
queryParams: {
|
|
from: "login-initiated",
|
|
},
|
|
});
|
|
}
|
|
|
|
protected async requestAdminApproval() {
|
|
await this.router.navigate(["/admin-approval-requested"]);
|
|
}
|
|
|
|
async logOut() {
|
|
const confirmed = await this.dialogService.openSimpleDialog({
|
|
title: { key: "logOut" },
|
|
content: { key: "logOutConfirmation" },
|
|
acceptButtonText: { key: "logOut" },
|
|
type: "warning",
|
|
});
|
|
|
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
|
if (confirmed) {
|
|
await this.logoutService.logout(userId);
|
|
// navigate to root so redirect guard can properly route next active user or null user to correct page
|
|
await this.router.navigate(["/"]);
|
|
}
|
|
}
|
|
}
|