1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 16:43:27 +00:00

Merge remote-tracking branch 'origin' into innovation/archive/desktop-work

This commit is contained in:
Patrick Pimentel
2025-05-16 14:29:11 -04:00
1337 changed files with 29908 additions and 24562 deletions

View File

@@ -50,9 +50,7 @@ export class CollectionsComponent implements OnInit {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.collectionIds = this.loadCipherCollections();
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.collections = await this.loadCollections();
this.collections.forEach((c) => ((c as any).checked = false));

View File

@@ -6,7 +6,6 @@ import { firstValueFrom } from "rxjs";
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -21,7 +20,6 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
protected successRoute = "/vault";
protected forcePasswordResetRoute = "/update-temp-password";
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
@@ -73,11 +71,6 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
await this.loginSuccessHandlerService.run(authResult.userId);
}
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.router.navigate([this.forcePasswordResetRoute]);
return;
}
await this.router.navigate([this.successRoute]);
} catch (error) {
if (error instanceof ErrorResponse) {

View File

@@ -1,67 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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 { ToastService } from "@bitwarden/components";
@Directive()
export abstract class CaptchaProtectedComponent {
@Input() captchaSiteKey: string = null;
captchaToken: string = null;
captcha: CaptchaIFrame;
constructor(
protected environmentService: EnvironmentService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected toastService: ToastService,
) {}
async setupCaptcha() {
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
this.captcha = new CaptchaIFrame(
window,
webVaultUrl,
this.i18nService,
(token: string) => {
this.captchaToken = token;
},
(error: string) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: error,
});
},
(info: string) => {
this.toastService.showToast({
variant: "info",
title: this.i18nService.t("info"),
message: info,
});
},
);
}
showCaptcha() {
return !Utils.isNullOrWhitespace(this.captchaSiteKey);
}
protected handleCaptchaRequired(response: { captchaSiteKey: string }): boolean {
if (Utils.isNullOrWhitespace(response.captchaSiteKey)) {
return false;
}
this.captchaSiteKey = response.captchaSiteKey;
this.captcha.init(response.captchaSiteKey);
return true;
}
}

View File

@@ -57,6 +57,7 @@ export interface EnvironmentSelectorRouteData {
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
standalone: false,
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();

View File

@@ -1,611 +0,0 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, Observable, of } from "rxjs";
import {
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
LoginStrategyServiceAbstraction,
FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption,
FakeUserDecryptionOptions as UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { SsoComponent } from "./sso.component";
// test component that extends the SsoComponent
@Component({})
class TestSsoComponent extends SsoComponent {}
interface SsoComponentProtected {
twoFactorRoute: string;
successRoute: string;
trustedDeviceEncRoute: string;
changePasswordRoute: string;
forcePasswordResetRoute: string;
logIn(code: string, codeVerifier: string, orgIdFromState: string): Promise<AuthResult>;
handleLoginError(e: any): Promise<void>;
}
// The ideal scenario would be to not have to test the protected / private methods of the SsoComponent
// but that will require a refactor of the SsoComponent class which is out of scope for now.
// This test suite allows us to be sure that the new Trusted Device encryption flows + mild refactors
// of the SsoComponent don't break the existing post login flows.
describe("SsoComponent", () => {
let component: TestSsoComponent;
let _component: SsoComponentProtected;
let fixture: ComponentFixture<TestSsoComponent>;
const userId = "userId" as UserId;
// Mock Services
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let mockRouter: MockProxy<Router>;
let mockI18nService: MockProxy<I18nService>;
let mockQueryParams: Observable<any>;
let mockActivatedRoute: ActivatedRoute;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockStateService: MockProxy<StateService>;
let mockToastService: MockProxy<ToastService>;
let mockApiService: MockProxy<ApiService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockLogService: MockProxy<LogService>;
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let mockConfigService: MockProxy<ConfigService>;
let mockMasterPasswordService: FakeMasterPasswordService;
let mockAccountService: FakeAccountService;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
// Mock authService.logIn params
let code: string;
let codeVerifier: string;
let orgIdFromState: string;
// Mock component callbacks
let mockOnSuccessfulLogin: jest.Mock;
let mockOnSuccessfulLoginNavigate: jest.Mock;
let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock;
let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock;
let mockOnSuccessfulLoginForceResetNavigate: jest.Mock;
let mockOnSuccessfulLoginTdeNavigate: jest.Mock;
let mockUserDecryptionOpts: {
noMasterPassword: UserDecryptionOptions;
withMasterPassword: UserDecryptionOptions;
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
};
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
beforeEach(() => {
// Mock Services
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
// Default mockQueryParams
mockQueryParams = of({ code: "code", state: "state" });
// Create a custom mock for ActivatedRoute with mock queryParams
mockActivatedRoute = {
queryParams: mockQueryParams,
} as any as ActivatedRoute;
mockSsoLoginService = mock();
mockStateService = mock();
mockToastService = mock();
mockApiService = mock();
mockCryptoFunctionService = mock();
mockEnvironmentService = mock();
mockPasswordGenerationService = mock();
mockLogService = mock();
mockUserDecryptionOptionsService = mock();
mockConfigService = mock();
mockAccountService = mockAccountServiceWith(userId);
mockMasterPasswordService = new FakeMasterPasswordService();
mockPlatformUtilsService = mock();
// Mock loginStrategyService.logIn params
code = "code";
codeVerifier = "codeVerifier";
orgIdFromState = "orgIdFromState";
// Mock component callbacks
mockOnSuccessfulLogin = jest.fn();
mockOnSuccessfulLoginNavigate = jest.fn();
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn();
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn();
mockOnSuccessfulLoginForceResetNavigate = jest.fn();
mockOnSuccessfulLoginTdeNavigate = jest.fn();
mockUserDecryptionOpts = {
noMasterPassword: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPassword: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndKeyConnector: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithKeyConnector: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
};
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
TestBed.configureTestingModule({
declarations: [TestSsoComponent],
providers: [
{ provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService },
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: StateService, useValue: mockStateService },
{ provide: ToastService, useValue: mockToastService },
{ provide: ApiService, useValue: mockApiService },
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockPasswordGenerationService,
},
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: mockUserDecryptionOptionsService,
},
{ provide: LogService, useValue: mockLogService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
],
});
fixture = TestBed.createComponent(TestSsoComponent);
component = fixture.componentInstance;
_component = component as any;
});
afterEach(() => {
// Reset all mocks after each test
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("navigateViaCallbackOrRoute(...)", () => {
it("calls the provided callback when callback is defined", async () => {
const callback = jest.fn().mockResolvedValue(null);
const commands = ["some", "route"];
await (component as any).navigateViaCallbackOrRoute(callback, commands);
expect(callback).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
it("calls router.navigate when callback is not defined", async () => {
const commands = ["some", "route"];
await (component as any).navigateViaCallbackOrRoute(undefined, commands);
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(commands, undefined);
});
});
describe("logIn(...)", () => {
describe("2FA scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
authResult.twoFactorProviders = { [TwoFactorProviderType.Authenticator]: {} };
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
it("calls authService.logIn and navigates to the component's defined 2FA route when the auth result requires 2FA and onSuccessfulLoginTwoFactorNavigate is not defined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTwoFactorNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.twoFactorRoute], {
queryParams: {
identifier: orgIdFromState,
sso: "true",
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginTwoFactorNavigate instead of router.navigate when response.requiresTwoFactor is true and the callback is defined", async () => {
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginTwoFactorNavigate = mockOnSuccessfulLoginTwoFactorNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTwoFactorNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
// Shared test helpers
const testChangePasswordOnSuccessfulLogin = () => {
it("navigates to the component's defined change password route when onSuccessfulLoginChangePasswordNavigate callback is undefined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testOnSuccessfulLoginChangePasswordNavigate = () => {
it("calls onSuccessfulLoginChangePasswordNavigate instead of router.navigate when the callback is defined", async () => {
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginChangePasswordNavigate =
mockOnSuccessfulLoginChangePasswordNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute when response.forcePasswordReset is ${reasonString}`, async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginForceResetNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testOnSuccessfulLoginForceResetNavigate = (reasonString: string) => {
it(`calls onSuccessfulLoginForceResetNavigate instead of router.navigate when response.forcePasswordReset is ${reasonString} and the callback is defined`, async () => {
mockOnSuccessfulLoginForceResetNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginForceResetNavigate = mockOnSuccessfulLoginForceResetNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginForceResetNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
describe("Trusted Device Encryption scenarios", () => {
beforeEach(() => {
mockConfigService.getFeatureFlag.mockResolvedValue(true); // TDE enabled
});
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
);
authResult = new AuthResult();
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
it("navigates to the component's defined trustedDeviceEncRoute route and sets correct flag when onSuccessfulLoginTdeNavigate is undefined ", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
expect(mockOnSuccessfulLoginTdeNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined,
);
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
// ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
testOnSuccessfulLoginForceResetNavigate(reasonString);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined,
);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
describe("Given user needs to set a master password", () => {
beforeEach(() => {
// Only need to test the case where the user has no master password to test the primary change mp flow here
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
});
testChangePasswordOnSuccessfulLogin();
testOnSuccessfulLoginChangePasswordNavigate();
});
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector);
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
// ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
testOnSuccessfulLoginForceResetNavigate(reasonString);
});
});
describe("Success scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
authResult.twoFactorProviders = null;
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
});
it("calls authService.logIn and navigates to the component's defined success route when the login is successful", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
expect(mockOnSuccessfulLogin).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLogin if defined when login is successful", async () => {
mockOnSuccessfulLogin = jest.fn().mockResolvedValue(null);
component.onSuccessfulLogin = mockOnSuccessfulLogin;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLogin).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginNavigate instead of router.navigate when login is successful and the callback is defined", async () => {
mockOnSuccessfulLoginNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginNavigate = mockOnSuccessfulLoginNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLoginNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
describe("Error scenarios", () => {
it("calls handleLoginError when an error is thrown during logIn", async () => {
const errorMessage = "Key Connector error";
const error = new Error(errorMessage);
mockLoginStrategyService.logIn.mockRejectedValue(error);
const handleLoginErrorSpy = jest.spyOn(_component, "handleLoginError");
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(handleLoginErrorSpy).toHaveBeenCalledWith(error);
});
});
});
describe("handleLoginError(e)", () => {
it("logs the error and shows a toast when the error message is 'Key Connector error'", async () => {
const errorMessage = "Key Connector error";
const error = new Error(errorMessage);
mockI18nService.t.mockReturnValueOnce("ssoKeyConnectorError");
await _component.handleLoginError(error);
expect(mockLogService.error).toHaveBeenCalledTimes(1);
expect(mockLogService.error).toHaveBeenCalledWith(error);
expect(mockToastService.showToast).toHaveBeenCalledTimes(1);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: null,
message: "ssoKeyConnectorError",
});
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,422 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import {
LoginStrategyServiceAbstraction,
SsoLoginCredentials,
TrustedDeviceUserDecryptionOption,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@Directive()
export class SsoComponent implements OnInit {
identifier: string;
loggingIn = false;
formPromise: Promise<AuthResult>;
initiateSsoFormPromise: Promise<SsoPreValidateResponse>;
onSuccessfulLogin: () => Promise<void>;
onSuccessfulLoginNavigate: () => Promise<void>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<void>;
onSuccessfulLoginChangePasswordNavigate: () => Promise<void>;
onSuccessfulLoginForceResetNavigate: () => Promise<void>;
onSuccessfulLoginTde: () => Promise<void>;
onSuccessfulLoginTdeNavigate: () => Promise<void>;
protected twoFactorRoute = "2fa";
protected successRoute = "lock";
protected trustedDeviceEncRoute = "login-initiated";
protected changePasswordRoute = "set-password-jit";
protected forcePasswordResetRoute = "update-temp-password";
protected clientId: string;
protected redirectUri: string;
protected state: string;
protected codeChallenge: string;
constructor(
protected ssoLoginService: SsoLoginServiceAbstraction,
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected router: Router,
protected i18nService: I18nService,
protected route: ActivatedRoute,
protected stateService: StateService,
protected platformUtilsService: PlatformUtilsService,
protected apiService: ApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected logService: LogService,
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
protected configService: ConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.code != null && qParams.state != null) {
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
const state = await this.ssoLoginService.getSsoState();
await this.ssoLoginService.setCodeVerifier(null);
await this.ssoLoginService.setSsoState(null);
if (qParams.redirectUri != null) {
this.redirectUri = qParams.redirectUri;
}
if (
qParams.code != null &&
codeVerifier != null &&
state != null &&
this.checkState(state, qParams.state)
) {
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state);
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
}
} else if (
qParams.clientId != null &&
qParams.redirectUri != null &&
qParams.state != null &&
qParams.codeChallenge != null
) {
this.redirectUri = qParams.redirectUri;
this.state = qParams.state;
this.codeChallenge = qParams.codeChallenge;
this.clientId = qParams.clientId;
}
});
}
async submit(returnUri?: string, includeUserIdentifier?: boolean) {
if (this.identifier == null || this.identifier === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("ssoValidationFailed"),
message: this.i18nService.t("ssoIdentifierRequired"),
});
return;
}
this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier);
const response = await this.initiateSsoFormPromise;
const authorizeUrl = await this.buildAuthorizeUrl(
returnUri,
includeUserIdentifier,
response.token,
);
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
}
protected async buildAuthorizeUrl(
returnUri?: string,
includeUserIdentifier?: boolean,
token?: string,
): Promise<string> {
let codeChallenge = this.codeChallenge;
let state = this.state;
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
if (codeChallenge == null) {
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
}
if (state == null) {
state = await this.passwordGenerationService.generatePassword(passwordOptions);
if (returnUri) {
state += `_returnUri='${returnUri}'`;
}
}
// Add Organization Identifier to state
state += `_identifier=${this.identifier}`;
// Save state (regardless of new or existing)
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
this.clientId +
"&redirect_uri=" +
encodeURIComponent(this.redirectUri) +
"&" +
"response_type=code&scope=api offline_access&" +
"state=" +
state +
"&code_challenge=" +
codeChallenge +
"&" +
"code_challenge_method=S256&response_mode=query&" +
"domain_hint=" +
encodeURIComponent(this.identifier) +
"&ssoToken=" +
encodeURIComponent(token);
if (includeUserIdentifier) {
const userIdentifier = await this.apiService.getSsoUserIdentifier();
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
}
return authorizeUrl;
}
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
this.loggingIn = true;
try {
const email = await this.ssoLoginService.getSsoEmail();
const credentials = new SsoLoginCredentials(
code,
codeVerifier,
this.redirectUri,
orgSsoIdentifier,
email,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const authResult = await this.formPromise;
if (authResult.requiresTwoFactor) {
return await this.handleTwoFactorRequired(orgSsoIdentifier);
}
// Everything after the 2FA check is considered a successful login
// Just have to figure out where to send the user
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
return await this.handleForcePasswordReset(orgSsoIdentifier);
}
// must come after 2fa check since user decryption options aren't available if 2fa is required
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
const tdeEnabled = await this.isTrustedDeviceEncEnabled(
userDecryptionOpts.trustedDeviceOption,
);
if (tdeEnabled) {
return await this.handleTrustedDeviceEncryptionEnabled(
authResult,
orgSsoIdentifier,
userDecryptionOpts,
);
}
// In the standard, non TDE case, a user must set password if they don't
// have one and they aren't using key connector.
// Note: TDE & Key connector are mutually exclusive org config options.
const requireSetPassword =
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgSsoIdentifier);
}
// Standard SSO login success case
return await this.handleSuccessfulLogin();
} catch (e) {
await this.handleLoginError(e);
}
}
private async isTrustedDeviceEncEnabled(
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
): Promise<boolean> {
return trustedDeviceOption !== undefined;
}
private async handleTwoFactorRequired(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTwoFactorNavigate,
[this.twoFactorRoute],
{
queryParams: {
identifier: orgIdentifier,
sso: "true",
},
},
);
}
private async handleTrustedDeviceEncryptionEnabled(
authResult: AuthResult,
orgIdentifier: string,
userDecryptionOpts: UserDecryptionOptions,
): Promise<void> {
// Tde offboarding takes precedence
if (
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.trustedDeviceOption.isTdeOffboarding
) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeOffboarding,
userId,
);
} else if (
// If user doesn't have a MP, but has reset password permission, they must set a MP
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
) {
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
// Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and
// if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key.
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
}
if (this.onSuccessfulLoginTde != null) {
// Don't await b/c causes hang on desktop & browser
// 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.onSuccessfulLoginTde();
}
// 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.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTdeNavigate,
// Navigate to TDE page (if user was on trusted device and TDE has decrypted
// their user key, the login-initiated guard will redirect them to the vault)
[this.trustedDeviceEncRoute],
);
}
private async handleChangePasswordRequired(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginChangePasswordNavigate,
[this.changePasswordRoute],
{
queryParams: {
identifier: orgIdentifier,
},
},
);
}
private async handleForcePasswordReset(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginForceResetNavigate,
[this.forcePasswordResetRoute],
{
queryParams: {
identifier: orgIdentifier,
},
},
);
}
private async handleSuccessfulLogin() {
if (this.onSuccessfulLogin != null) {
// Don't await b/c causes hang on desktop & browser
// 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.onSuccessfulLogin();
}
await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]);
}
private async handleLoginError(e: any) {
this.logService.error(e);
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
if (e.message === "Key Connector error") {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("ssoKeyConnectorError"),
});
}
}
private async navigateViaCallbackOrRoute(
callback: () => Promise<unknown>,
commands: unknown[],
extras?: NavigationExtras,
): Promise<void> {
if (callback) {
await callback();
} else {
await this.router.navigate(commands, extras);
}
}
private getOrgIdentifierFromState(state: string): string {
if (state === null || state === undefined) {
return null;
}
const stateSplit = state.split("_identifier=");
return stateSplit.length > 1 ? stateSplit[1] : null;
}
private checkState(state: string, checkState: string): boolean {
if (state === null || state === undefined) {
return false;
}
if (checkState === null || checkState === undefined) {
return false;
}
const stateSplit = state.split("_identifier=");
const checkStateSplit = checkState.split("_identifier=");
return stateSplit[0] === checkStateSplit[0];
}
}

View File

@@ -10,6 +10,7 @@ import { WebAuthnIcon } from "../icons/webauthn.icon";
@Component({
selector: "auth-two-factor-icon",
templateUrl: "./two-factor-icon.component.html",
standalone: false,
})
export class TwoFactorIconComponent {
@Input() provider: any;

View File

@@ -22,6 +22,7 @@ import { KeyService } from "@bitwarden/key-management";
*/
@Directive({
selector: "app-user-verification",
standalone: false,
})
export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy {
private _invalidSecret = false;

View File

@@ -22,6 +22,8 @@ export type AddAccountCreditDialogParams = {
providerId?: string;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AddAccountCreditDialogResultType {
Closed = "closed",
Submitted = "submitted",
@@ -46,6 +48,7 @@ type PayPalConfig = {
@Component({
templateUrl: "./add-account-credit-dialog.component.html",
standalone: false,
})
export class AddAccountCreditDialogComponent implements OnInit {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef;

View File

@@ -11,6 +11,7 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
@Component({
selector: "app-invoices",
templateUrl: "./invoices.component.html",
standalone: false,
})
export class InvoicesComponent implements OnInit {
@Input() startWith?: InvoicesResponse;

View File

@@ -30,6 +30,7 @@ const partnerTrustIcon = svgIcon`
<bit-icon [icon]="icon"></bit-icon>
<p class="tw-mt-4">{{ "noInvoicesToList" | i18n }}</p>
</div>`,
standalone: false,
})
export class NoInvoicesComponent {
icon = partnerTrustIcon;

View File

@@ -11,6 +11,7 @@ import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/model
@Component({
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
@Input() startWith: TaxInformation;

View File

@@ -9,6 +9,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
*/
@Directive({
selector: "[appNotPremium]",
standalone: false,
})
export class NotPremiumDirective implements OnInit {
constructor(

View File

@@ -12,6 +12,7 @@ import { CalloutTypes } from "@bitwarden/components";
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
standalone: false,
})
export class DeprecatedCalloutComponent implements OnInit {
@Input() type: CalloutTypes = "info";

View File

@@ -18,6 +18,7 @@ import { ModalRef } from "./modal.ref";
@Component({
selector: "app-modal",
template: "<ng-template #modalContent></ng-template>",
standalone: false,
})
export class DynamicModalComponent implements AfterViewInit, OnDestroy {
componentRef: ComponentRef<any>;

View File

@@ -76,9 +76,7 @@ export class ShareComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
this.cipher = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipherDomain, activeUserId);
}
filterCollections() {
@@ -105,9 +103,7 @@ export class ShareComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
const cipherView = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);
const cipherView = await this.cipherService.decrypt(cipherDomain, activeUserId);
const orgs = await firstValueFrom(this.organizations$);
const orgName =
orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization");

View File

@@ -6,6 +6,7 @@ import { Subscription } from "rxjs";
@Directive({
selector: "[appA11yInvalid]",
standalone: false,
})
export class A11yInvalidDirective implements OnDestroy, OnInit {
private sub: Subscription;

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { Directive, ElementRef, Input, OnChanges } from "@angular/core";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -16,6 +15,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
*/
@Directive({
selector: "[appApiAction]",
standalone: false,
})
export class ApiActionDirective implements OnChanges {
@Input() appApiAction: Promise<any>;
@@ -39,11 +39,6 @@ export class ApiActionDirective implements OnChanges {
},
(e: any) => {
this.el.nativeElement.loading = false;
if ((e as ErrorResponse).captchaRequired) {
this.logService.error("Captcha required error response: " + e.getSingleMessage());
return;
}
this.logService?.error(`Received API exception:`, e);
this.validationService.showError(e);
},

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, HostListener, OnInit } from "@angular/core";
@Directive({
selector: "[appBoxRow]",
standalone: false,
})
export class BoxRowDirective implements OnInit {
el: HTMLElement = null;

View File

@@ -7,6 +7,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
@Directive({
selector: "[appCopyText]",
standalone: false,
})
export class CopyTextDirective {
constructor(

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core";
@Directive({
selector: "[appFallbackSrc]",
standalone: false,
})
export class FallbackSrcDirective {
@Input("appFallbackSrc") appFallbackSrc: string;

View File

@@ -14,6 +14,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
*/
@Directive({
selector: "[appIfFeature]",
standalone: false,
})
export class IfFeatureDirective implements OnInit {
/**

View File

@@ -5,6 +5,7 @@ import { NgControl } from "@angular/forms";
@Directive({
selector: "input[appInputStripSpaces]",
standalone: false,
})
export class InputStripSpacesDirective {
constructor(

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
@Directive({
selector: "[appInputVerbatim]",
standalone: false,
})
export class InputVerbatimDirective implements OnInit {
@Input() set appInputVerbatim(condition: boolean | string) {

View File

@@ -5,6 +5,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
@Directive({
selector: "[appLaunchClick]",
standalone: false,
})
export class LaunchClickDirective {
constructor(private platformUtilsService: PlatformUtilsService) {}

View File

@@ -2,6 +2,7 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopClick]",
standalone: false,
})
export class StopClickDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -2,6 +2,7 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopProp]",
standalone: false,
})
export class StopPropDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -11,6 +11,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
multi: true,
},
],
standalone: false,
})
export class TrueFalseValueDirective implements ControlValueAccessor {
@Input() trueValue: boolean | string = true;

View File

@@ -7,7 +7,10 @@ import { ColorPasswordPipe } from "./color-password.pipe";
/*
An updated pipe that extends ColourPasswordPipe to include a character count
*/
@Pipe({ name: "colorPasswordCount" })
@Pipe({
name: "colorPasswordCount",
standalone: false,
})
export class ColorPasswordCountPipe extends ColorPasswordPipe {
transform(password: string) {
const template = (character: string, type: string, index: number) =>

View File

@@ -6,7 +6,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
An updated pipe that sanitizes HTML, highlights numbers and special characters (in different colors each)
and handles Unicode / Emoji characters correctly.
*/
@Pipe({ name: "colorPassword" })
@Pipe({
name: "colorPassword",
standalone: false,
})
export class ColorPasswordPipe implements PipeTransform {
transform(password: string) {
const template = (character: string, type: string) =>

View File

@@ -28,7 +28,10 @@ const numberFormats: Record<string, CardRuleEntry[]> = {
Other: [{ cardLength: 16, blocks: [4, 4, 4, 4] }],
};
@Pipe({ name: "creditCardNumber" })
@Pipe({
name: "creditCardNumber",
standalone: false,
})
export class CreditCardNumberPipe implements PipeTransform {
transform(creditCardNumber: string, brand: string): string {
let rules = numberFormats[brand];

View File

@@ -4,6 +4,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Pipe({
name: "searchCiphers",
standalone: false,
})
export class SearchCiphersPipe implements PipeTransform {
transform(ciphers: CipherView[], searchText: string, deleted = false): CipherView[] {

View File

@@ -6,6 +6,7 @@ type PropertyValueFunction<T> = (item: T) => { toString: () => string };
@Pipe({
name: "search",
standalone: false,
})
export class SearchPipe implements PipeTransform {
transform<T>(

View File

@@ -9,6 +9,7 @@ export interface User {
@Pipe({
name: "userName",
standalone: false,
})
export class UserNamePipe implements PipeTransform {
transform(user?: User): string {

View File

@@ -5,6 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
@Pipe({
name: "userType",
standalone: false,
})
export class UserTypePipe implements PipeTransform {
constructor(private i18nService: I18nService) {}

View File

@@ -2,6 +2,7 @@ import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "ellipsis",
standalone: false,
})
/**
* @deprecated Use the tailwind class 'tw-truncate' instead

View File

@@ -5,6 +5,7 @@ import { KeyService } from "@bitwarden/key-management";
@Pipe({
name: "fingerprint",
standalone: false,
})
export class FingerprintPipe {
constructor(private keyService: KeyService) {}

View File

@@ -7,6 +7,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
*/
@Pipe({
name: "i18n",
standalone: false,
})
export class I18nPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}

View File

@@ -0,0 +1 @@
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";

View File

@@ -0,0 +1 @@
export { NoopViewCacheService } from "./noop-view-cache.service";

View File

@@ -1,11 +1,7 @@
import { Injectable, signal, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "../abstractions/view-cache.service";
import { FormCacheOptions, SignalCacheOptions, ViewCacheService } from "./view-cache.service";
/**
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,

View File

@@ -0,0 +1,130 @@
# Extension Persistence
By default, when the browser extension popup closes, the user's current view and any data entered
without saving is lost. This introduces friction in several workflows within our client, such as:
- Performing actions that require email OTP entry, since the user must navigate from the popup to
get to their email inbox
- Entering information to create a new vault item from a browser tab
- And many more
Previously, we have recommended that users "pop out" the extension into its own window to persist
the extension context, but this introduces additional user actions and may leave the extension open
(and unlocked) for longer than a user intends.
In order to provide a better user experience, we have introduced two levels of persistence to the
Bitwarden extension client:
- We persist the route history, allowing us to re-open the last route when the popup re-opens, and
- We offer a service for teams to use to persist component-specific form data or state to survive a
popup close/re-open cycle
## Persistence lifetime
Since we are persisting data, it is important that the lifetime of that data be well-understood and
well-constrained. The cache of route history and form data is cleared when any of the following
events occur:
- The account is locked
- The account is logged out
- Account switching is used to switch the active account
- The extension popup has been closed for 2 minutes
In addition, cached form data is cleared when a browser extension navigation event occurs (e.g.
switching between tabs in the extension).
## Types of persistence
### Route history persistence
Route history is persisted on the extension automatically, with no specific implementation required
on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
### View data persistence
Route persistence ensures that the user will land back on the route that they were on when the popup
closed, but it does not persist any state or form data that the user may have modified. In order to
persist that data, the component is responsible for registering that data with the
[`ViewCacheService`](./view-cache.service.ts).
This is done prescriptively to ensure that only necessary data is cached and that it is done with
intention by the component.
The `ViewCacheService` provides an interface for caching both individual state and `FormGroup`s.
#### Caching individual data elements
For individual pieces of state, use the `signal()` method on the `ViewCacheService` to create a
writeable [signal](https://angular.dev/guide/signals) wrapper around the desired state.
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
```
If a cached value exists, the returned signal will contain the cached data.
Setting the value should be done through the signal's `set()` method:
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
Putting this together, the most common implementation pattern would be:
1. **Register the signal** using `ViewCacheService.signal()` on initialization of the component or
service responsible for the state being persisted.
2. **Restore state from the signal:** If cached data exists, the signal will contain that data. The
component or service should use this data to re-create the state from prior to the popup closing.
3. **Set new state** in the cache when it changes. Ensure that any updates to the data are persisted
to the cache with `set()`, so that the cache reflects the latest state.
#### Caching form data
For persisting form data, the `ViewCacheService` supplies a `formGroup()` method, which manages the
persistence of any entered form data to the cache and the initialization of the form from the cached
data. You can supply the `FormGroup` in the `control` parameter of the method, and the
`ViewCacheService` will:
- Initialize the form the a cached value, if it exists
- Save form value to cache when it changes
- Mark the form dirty if the restored value is not `undefined`.
```typescript
this.loginDetailsForm = this.viewCacheService.formGroup({
key: "my-form",
control: this.formBuilder.group({
username: [""],
email: [""],
}),
});
```
## What about other clients?
The `ViewCacheService` is designed to be injected into shared, client-agnostic components. A
`NoopViewCacheService` is provided and injected for non-extension clients, preserving a single
interface for your components.

View File

@@ -42,6 +42,8 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
/**
* Cache for temporary component state
*
* [Read more](./view-cache.md)
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close
* - all other clients: noop

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -101,200 +101,101 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default;
// For new icons - add their glyph name and value to the map below
$icons: (
"universal-access": "\e991",
"save-changes": "\e988",
"browser": "\e985",
"browser-alt": "\e9a3",
"mobile": "\e986",
"mobile-alt": "\e9a4",
"cli": "\e987",
"providers": "\e983",
"vault": "\e984",
"vault-f": "\e9ab",
"folder-closed-f": "\e982",
"rocket": "\e9ee",
"ellipsis-h": "\e9ef",
"ellipsis-v": "\e9f0",
"safari": "\e974",
"opera": "\e975",
"firefox": "\e976",
"edge": "\e977",
"chrome": "\e978",
"star-f": "\e979",
"arrow-circle-up": "\e97a",
"arrow-circle-right": "\e97b",
"arrow-circle-left": "\e97c",
"arrow-circle-down": "\e97d",
"undo": "\e97e",
"bolt": "\e97f",
"puzzle": "\e980",
"rss": "\e973",
"dbl-angle-left": "\e970",
"dbl-angle-right": "\e971",
"hamburger": "\e972",
"bw-folder-open-f1": "\e93e",
"desktop": "\e96a",
"desktop-alt": "\e9a2",
"angle-up": "\e969",
"user": "\e900",
"user-f": "\e901",
"user-monitor": "\e9a7",
"key": "\e902",
"share-square": "\e903",
"hashtag": "\e904",
"clone": "\e905",
"list-alt": "\e906",
"id-card": "\e907",
"credit-card": "\e908",
"globe": "\e909",
"sticky-note": "\e90a",
"folder": "\e90b",
"lock": "\e90c",
"lock-f": "\e90d",
"generate": "\e90e",
"generate-f": "\e90f",
"cog": "\e910",
"cog-f": "\e911",
"check-circle": "\e912",
"eye": "\e913",
"pencil-square": "\e914",
"bookmark": "\e915",
"files": "\e916",
"trash": "\e917",
"plus": "\e918",
"plus-f": "\e9a9",
"star": "\e919",
"list": "\e91a",
"angle-down": "\e92d",
"external-link": "\e91c",
"refresh": "\e91d",
"search": "\e91f",
"filter": "\e920",
"plus-circle": "\e921",
"user-circle": "\e922",
"question-circle": "\e923",
"cogs": "\e924",
"minus-circle": "\e925",
"send": "\e926",
"send-f": "\e927",
"download": "\e928",
"pencil": "\e929",
"sign-out": "\e92a",
"share": "\e92b",
"clock": "\e92c",
"angle-left": "\e96b",
"caret-left": "\e92e",
"square": "\e92f",
"collection": "\e930",
"bank": "\e931",
"shield": "\e932",
"stop": "\e933",
"plus-square": "\e934",
"save": "\e935",
"sign-in": "\e936",
"spinner": "\e937",
"dollar": "\e939",
"check": "\e93a",
"check-square": "\e93b",
"minus-square": "\e93c",
"close": "\e93d",
"share-arrow": "\e96c",
"paperclip": "\e93f",
"bitcoin": "\e940",
"cut": "\e941",
"frown": "\e942",
"folder-open": "\e943",
"bug": "\e946",
"chain-broken": "\e947",
"dashboard": "\e948",
"envelope": "\e949",
"exclamation-circle": "\e94a",
"exclamation-triangle": "\e94b",
"caret-right": "\e94c",
"file-pdf": "\e94e",
"file-text": "\e94f",
"info-circle": "\e952",
"lightbulb": "\e953",
"link": "\e954",
"linux": "\e956",
"long-arrow-right": "\e957",
"money": "\e958",
"play": "\e959",
"reddit": "\e95a",
"refresh-tab": "\e95b",
"sitemap": "\e95c",
"sliders": "\e95d",
"tag": "\e95e",
"thumb-tack": "\e95f",
"thumbs-up": "\e960",
"unlock": "\e962",
"users": "\e963",
"wrench": "\e965",
"ban": "\e967",
"camera": "\e968",
"angle-right": "\e91b",
"eye-slash": "\e96d",
"file": "\e96e",
"paste": "\e96f",
"github": "\e950",
"facebook": "\e94d",
"paypal": "\e938",
"brave": "\e951",
"google": "\e9a5",
"duckduckgo": "\e9bb",
"tor": "\e9bc",
"vivaldi": "\e9bd",
"linkedin": "\e955",
"discourse": "\e91e",
"twitter": "\e961",
"x-twitter": "\e9be",
"youtube": "\e966",
"windows": "\e964",
"apple": "\e945",
"android": "\e944",
"error": "\e981",
"numbered-list": "\e989",
"billing": "\e98a",
"family": "\e98b",
"provider": "\e98c",
"business": "\e98d",
"learning": "\e98e",
"chat": "\e990",
"server": "\e98f",
"search-book": "\e992",
"twitch": "\e993",
"community": "\e994",
"mastodon": "\e995",
"insurance": "\e996",
"wireless": "\e997",
"software-license": "\e998",
"instagram": "\e999",
"down-solid": "\e99a",
"up-solid": "\e99b",
"up-down-btn": "\e99c",
"caret-up": "\e99d",
"caret-down": "\e99e",
"passkey": "\e99f",
"lock-encrypted": "\e9a0",
"back": "\e9a8",
"popout": "\e9aa",
"wand": "\e9a6",
"msp": "\e9a1",
"totp-codes-alt": "\e9ac",
"totp-codes-alt2": "\e9ad",
"totp-codes": "\e9ae",
"authenticator": "\e9af",
"fingerprint": "\e9b0",
"expired": "\e9ba",
"icon-1": "\e9b1",
"icon-2": "\e9b2",
"icon-3": "\e9b3",
"icon-4": "\e9b4",
"icon-5": "\e9b5",
"icon-6": "\e9b6",
"icon-7": "\e9b7",
"icon-8": "\e9b8",
"icon-9": "\e9b9",
"angle-down": "\e900",
"angle-left": "\e901",
"angle-right": "\e902",
"angle-up": "\e903",
"bell": "\e904",
"billing": "\e905",
"bitcoin": "\e906",
"browser-alt": "\e907",
"browser": "\e908",
"brush": "\e909",
"bug": "\e90a",
"business": "\e90b",
"camera": "\e90c",
"check-circle": "\e90e",
"check": "\e90f",
"cli": "\e910",
"clock": "\e911",
"close": "\e912",
"cog-f": "\e913",
"cog": "\e914",
"collection": "\e915",
"collection-shared": "\e916",
"clone": "\e917",
"dollar": "\e919",
"down-solid": "\e91a",
"download": "\e91b",
"drag-and-drop": "\e91c",
"ellipsis-h": "\e91d",
"ellipsis-v": "\e91e",
"envelope": "\e91f",
"error": "\e920",
"exclamation-triangle": "\e921",
"external-link": "\e922",
"eye-slash": "\e923",
"eye": "\e924",
"family": "\e925",
"file-text": "\e926",
"file": "\e927",
"files": "\e928",
"filter": "\e929",
"folder": "\e92a",
"generate": "\e92b",
"globe": "\e92c",
"hashtag": "\e92d",
"id-card": "\e92e",
"info-circle": "\e92f",
"import": "\e930",
"key": "\e931",
"list-alt": "\e933",
"list": "\e934",
"lock-encrypted": "\e935",
"lock-f": "\e936",
"lock": "\e937",
"shield": "\e938",
"minus-circle": "\e939",
"mobile": "\e93a",
"msp": "\e93b",
"sticky-note": "\e93c",
"numbered-list": "\e93d",
"paperclip": "\e93e",
"passkey": "\e93f",
"pencil-square": "\e940",
"pencil": "\e941",
"plus-circle": "\e942",
"plus": "\e943",
"popout": "\e944",
"provider": "\e945",
"puzzle": "\e946",
"question-circle": "\e947",
"refresh": "\e948",
"search": "\e949",
"send": "\e94a",
"share": "\e94b",
"sign-in": "\e94c",
"sign-out": "\e94d",
"sliders": "\e94e",
"spinner": "\e94f",
"star-f": "\e950",
"star": "\e951",
"tag": "\e952",
"trash": "\e953",
"undo": "\e954",
"universal-access": "\e955",
"unlock": "\e956",
"up-down-btn": "\e957",
"up-solid": "\e958",
"user-monitor": "\e959",
"user": "\e95a",
"users": "\e95b",
"vault": "\e95c",
"wireless": "\e95d",
"wrench": "\e95e",
"paypal": "\e95f",
"credit-card": "\e9a2",
"desktop": "\e9a3",
"archive": "\e9c1",
);

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "DM Sans";
font-family: Roboto;
src:
url("webfonts/dm-sans.woff2") format("woff2 supports variations"),
url("webfonts/dm-sans.woff2") format("woff2-variations");
url("webfonts/roboto.woff2") format("woff2 supports variations"),
url("webfonts/roboto.woff2") format("woff2-variations");
font-display: swap;
font-weight: 100 900;
}

Binary file not shown.

View File

@@ -13,6 +13,7 @@ import {
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { HttpOperations } from "@bitwarden/common/services/api.service";
import { SafeInjectionToken } from "@bitwarden/ui-common";
// Re-export the SafeInjectionToken from ui-common
export { SafeInjectionToken } from "@bitwarden/ui-common";
@@ -61,3 +62,5 @@ export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() =>
export const ENV_ADDITIONAL_REGIONS = new SafeInjectionToken<RegionConfig[]>(
"ENV_ADDITIONAL_REGIONS",
);
export const HTTP_OPERATIONS = new SafeInjectionToken<HttpOperations>("HTTP_OPERATIONS");

View File

@@ -263,6 +263,7 @@ import {
InternalSendService,
SendService as SendServiceAbstraction,
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -281,6 +282,7 @@ import {
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
@@ -312,7 +314,7 @@ import {
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
@@ -325,18 +327,20 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import { ViewCacheService } from "../platform/view-cache";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
ENV_ADDITIONAL_REGIONS,
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
@@ -507,6 +511,7 @@ const safeProviders: SafeProvider[] = [
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
) =>
new CipherService(
keyService,
@@ -523,6 +528,7 @@ const safeProviders: SafeProvider[] = [
stateProvider,
accountService,
logService,
cipherEncryptionService,
),
deps: [
KeyService,
@@ -539,6 +545,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
AccountServiceAbstraction,
LogService,
CipherEncryptionService,
],
}),
safeProvider({
@@ -586,7 +593,11 @@ const safeProviders: SafeProvider[] = [
useClass: AvatarService,
deps: [ApiServiceAbstraction, StateProvider],
}),
safeProvider({ provide: LogService, useFactory: () => new ConsoleLogService(false), deps: [] }),
safeProvider({
provide: LogService,
useFactory: () => new ConsoleLogService(process.env.NODE_ENV === "development"),
deps: [],
}),
safeProvider({
provide: CollectionService,
useClass: DefaultCollectionService,
@@ -696,6 +707,10 @@ const safeProviders: SafeProvider[] = [
},
deps: [ToastService, I18nServiceAbstraction],
}),
safeProvider({
provide: HTTP_OPERATIONS,
useValue: { createRequest: (url, request) => new Request(url, request) },
}),
safeProvider({
provide: ApiServiceAbstraction,
useClass: ApiService,
@@ -708,6 +723,7 @@ const safeProviders: SafeProvider[] = [
LogService,
LOGOUT_CALLBACK,
VaultTimeoutSettingsService,
HTTP_OPERATIONS,
],
}),
safeProvider({
@@ -1068,7 +1084,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationSponsorshipApiServiceAbstraction,
useClass: OrganizationSponsorshipApiService,
deps: [ApiServiceAbstraction],
deps: [ApiServiceAbstraction, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: OrganizationBillingApiServiceAbstraction,
@@ -1455,7 +1471,6 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction],
}),
safeProvider(NewDeviceVerificationNoticeService),
safeProvider({
provide: UserAsymmetricKeysRegenerationApiService,
useClass: DefaultUserAsymmetricKeysRegenerationApiService,
@@ -1518,6 +1533,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: CipherEncryptionService,
useClass: DefaultCipherEncryptionService,
deps: [SdkService, LogService],
}),
];
@NgModule({

View File

@@ -16,6 +16,7 @@ export interface PasswordColorText {
@Component({
selector: "app-password-strength",
templateUrl: "password-strength.component.html",
standalone: false,
})
export class PasswordStrengthComponent implements OnChanges {
@Input() showText = false;

View File

@@ -36,6 +36,8 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService, ToastService } from "@bitwarden/components";
// Value = hours
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum DatePreset {
OneHour = 1,
OneDay = 24,

View File

@@ -269,9 +269,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (this.cipher == null) {
if (this.editMode) {
const cipher = await this.loadCipher(activeUserId);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
// Adjust Cipher Name if Cloning
if (this.cloneMode) {

View File

@@ -9,13 +9,13 @@ import { getUserId } from "@bitwarden/common/auth/services/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 { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -56,6 +56,7 @@ export class AttachmentsComponent implements OnInit {
protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
protected toastService: ToastService,
protected configService: ConfigService,
) {}
async ngOnInit() {
@@ -88,9 +89,7 @@ export class AttachmentsComponent implements OnInit {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.formPromise = this.saveCipherAttachment(files[0], activeUserId);
this.cipherDomain = await this.formPromise;
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -130,9 +129,7 @@ export class AttachmentsComponent implements OnInit {
const updatedCipher = await this.deletePromises[attachment.id];
const cipher = new Cipher(updatedCipher);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
this.toastService.showToast({
variant: "success",
@@ -197,12 +194,14 @@ export class AttachmentsComponent implements OnInit {
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
@@ -228,9 +227,7 @@ export class AttachmentsComponent implements OnInit {
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
@@ -276,15 +273,17 @@ export class AttachmentsComponent implements OnInit {
try {
// 2. Resave
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer(
this.cipherDomain,
attachment.fileName,
@@ -292,9 +291,7 @@ export class AttachmentsComponent implements OnInit {
activeUserId,
admin,
);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
// 3. Delete old
this.deletePromises[attachment.id] = this.deleteCipherAttachment(
@@ -343,7 +340,12 @@ export class AttachmentsComponent implements OnInit {
}
protected deleteCipherAttachment(attachmentId: string, userId: UserId) {
return this.cipherService.deleteAttachmentWithServer(this.cipher.id, attachmentId, userId);
return this.cipherService.deleteAttachmentWithServer(
this.cipher.id,
attachmentId,
userId,
false,
);
}
protected async reupload(attachment: AttachmentView) {

View File

@@ -19,6 +19,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
selector: "app-vault-icon",
templateUrl: "icon.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class IconComponent {
/**

View File

@@ -42,9 +42,7 @@ export class PasswordHistoryComponent implements OnInit {
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.cipherService.get(this.cipherId, activeUserId);
const decCipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const decCipher = await this.cipherService.decrypt(cipher, activeUserId);
this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory;
}
}

View File

@@ -34,12 +34,12 @@ import { EventType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -139,6 +139,7 @@ export class ViewComponent implements OnDestroy, OnInit {
private billingAccountProfileStateService: BillingAccountProfileStateService,
protected toastService: ToastService,
private cipherAuthorizationService: CipherAuthorizationService,
protected configService: ConfigService,
) {}
ngOnInit() {
@@ -254,7 +255,7 @@ export class ViewComponent implements OnDestroy, OnInit {
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.unarchiveWithServer(this.cipher.id as CipherId, activeUserId)
await this.cipherService.unarchiveWithServer(this.cipher.id as CipherId, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -514,19 +515,19 @@ export class ViewComponent implements OnDestroy, OnInit {
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipher.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
});
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
this.toastService.showToast({
variant: "error",
title: null,

View File

@@ -1 +0,0 @@
export * from "./new-device-verification-notice.guard";

View File

@@ -1,293 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
import { VaultProfileService } from "../services/vault-profile.service";
import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard";
describe("NewDeviceVerificationNoticeGuard", () => {
const _state = Object.freeze({}) as RouterStateSnapshot;
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
const eightDaysAgo = new Date();
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
const account = {
id: "account-id",
} as unknown as Account;
const activeAccount$ = new BehaviorSubject<Account | null>(account);
const createUrlTree = jest.fn();
const getFeatureFlag = jest.fn().mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
const isSelfHost = jest.fn().mockReturnValue(false);
const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false);
const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
const skipState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
const getProfileCreationDate = jest.fn().mockResolvedValue(eightDaysAgo);
const hasMasterPasswordAndMasterKeyHash = jest.fn().mockResolvedValue(true);
const getUserSSOBound = jest.fn().mockResolvedValue(false);
const getUserSSOBoundAdminOwner = jest.fn().mockResolvedValue(false);
beforeEach(() => {
getFeatureFlag.mockClear();
isSelfHost.mockClear();
getProfileCreationDate.mockClear();
getProfileTwoFactorEnabled.mockClear();
createUrlTree.mockClear();
hasMasterPasswordAndMasterKeyHash.mockClear();
getUserSSOBound.mockClear();
getUserSSOBoundAdminOwner.mockClear();
skipState$.mockClear();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: NewDeviceVerificationNoticeService, useValue: { noticeState$, skipState$ } },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: PlatformUtilsService, useValue: { isSelfHost } },
{ provide: UserVerificationService, useValue: { hasMasterPasswordAndMasterKeyHash } },
{
provide: VaultProfileService,
useValue: {
getProfileCreationDate,
getProfileTwoFactorEnabled,
getUserSSOBound,
getUserSSOBoundAdminOwner,
},
},
],
});
});
function newDeviceGuard(route?: ActivatedRouteSnapshot) {
// Run the guard within injection context so `inject` works as you'd expect
// Pass state object to make TypeScript happy
return TestBed.runInInjectionContext(async () =>
NewDeviceVerificationNoticeGuard(route ?? emptyRoute, _state),
);
}
describe("fromNewDeviceVerification", () => {
const route = {
queryParams: { fromNewDeviceVerification: "true" },
} as unknown as ActivatedRouteSnapshot;
it("returns `true` when `fromNewDeviceVerification` is present", async () => {
expect(await newDeviceGuard(route)).toBe(true);
});
it("does not execute other logic", async () => {
// `fromNewDeviceVerification` param should exit early,
// not foolproof but a quick way to test that other logic isn't executed
await newDeviceGuard(route);
expect(getFeatureFlag).not.toHaveBeenCalled();
expect(isSelfHost).not.toHaveBeenCalled();
expect(getProfileTwoFactorEnabled).not.toHaveBeenCalled();
expect(getProfileCreationDate).not.toHaveBeenCalled();
expect(hasMasterPasswordAndMasterKeyHash).not.toHaveBeenCalled();
});
});
describe("missing current account", () => {
afterAll(() => {
// reset `activeAccount$` observable
activeAccount$.next(account);
});
it("redirects to login when account is missing", async () => {
activeAccount$.next(null);
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
});
});
it("returns `true` when 2FA is enabled", async () => {
getProfileTwoFactorEnabled.mockResolvedValueOnce(true);
expect(await newDeviceGuard()).toBe(true);
});
it("returns `true` when the user is self hosted", async () => {
isSelfHost.mockReturnValueOnce(true);
expect(await newDeviceGuard()).toBe(true);
});
it("returns `true` when the profile was created less than a week ago", async () => {
const sixDaysAgo = new Date();
sixDaysAgo.setDate(sixDaysAgo.getDate() - 6);
getProfileCreationDate.mockResolvedValueOnce(sixDaysAgo);
expect(await newDeviceGuard()).toBe(true);
});
it("returns `true` when the profile service throws an error", async () => {
getProfileCreationDate.mockRejectedValueOnce(new Error("test"));
expect(await newDeviceGuard()).toBe(true);
});
it("returns `true` when the skip state value is set to true", async () => {
skipState$.mockReturnValueOnce(new BehaviorSubject(true));
expect(await newDeviceGuard()).toBe(true);
expect(skipState$.mock.calls[0][0]).toBe("account-id");
expect(skipState$.mock.calls.length).toBe(1);
});
describe("SSO bound", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
afterAll(() => {
getFeatureFlag.mockReturnValue(false);
});
it('returns "true" when the user is SSO bound and not an admin or owner', async () => {
getUserSSOBound.mockResolvedValueOnce(true);
getUserSSOBoundAdminOwner.mockResolvedValueOnce(false);
expect(await newDeviceGuard()).toBe(true);
});
it('returns "true" when the user is an admin or owner of an SSO bound organization and has not logged in with their master password', async () => {
getUserSSOBound.mockResolvedValueOnce(true);
getUserSSOBoundAdminOwner.mockResolvedValueOnce(true);
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(false);
expect(await newDeviceGuard()).toBe(true);
});
it("shows notice when the user is an admin or owner of an SSO bound organization and logged in with their master password", async () => {
getUserSSOBound.mockResolvedValueOnce(true);
getUserSSOBoundAdminOwner.mockResolvedValueOnce(true);
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(true);
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});
it("shows notice when the user that is not in an SSO bound organization", async () => {
getUserSSOBound.mockResolvedValueOnce(false);
getUserSSOBoundAdminOwner.mockResolvedValueOnce(false);
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(true);
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});
});
describe("temp flag", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
afterAll(() => {
getFeatureFlag.mockReturnValue(false);
});
it("redirects to notice when the user has not dismissed it", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
expect(noticeState$).toHaveBeenCalledWith(account.id);
});
it("redirects to notice when the user dismissed it more than 7 days ago", async () => {
const eighteenDaysAgo = new Date();
eighteenDaysAgo.setDate(eighteenDaysAgo.getDate() - 18);
noticeState$.mockReturnValueOnce(
new BehaviorSubject({ last_dismissal: eighteenDaysAgo.toISOString() }),
);
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});
it("returns true when the user dismissed less than 7 days ago", async () => {
const fourDaysAgo = new Date();
fourDaysAgo.setDate(fourDaysAgo.getDate() - 4);
noticeState$.mockReturnValueOnce(
new BehaviorSubject({ last_dismissal: fourDaysAgo.toISOString() }),
);
expect(await newDeviceGuard()).toBe(true);
});
});
describe("permanent flag", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
afterAll(() => {
getFeatureFlag.mockReturnValue(false);
});
it("redirects when the user has not dismissed", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: null }));
await newDeviceGuard();
expect(createUrlTree).toHaveBeenCalledTimes(2);
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});
it("returns `true` when the user has dismissed", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: true }));
expect(await newDeviceGuard()).toBe(true);
});
});
});

View File

@@ -1,166 +0,0 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
import { VaultProfileService } from "../services/vault-profile.service";
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
) => {
const router = inject(Router);
const configService = inject(ConfigService);
const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService);
const accountService = inject(AccountService);
const platformUtilsService = inject(PlatformUtilsService);
const vaultProfileService = inject(VaultProfileService);
const userVerificationService = inject(UserVerificationService);
if (route.queryParams["fromNewDeviceVerification"]) {
return true;
}
const tempNoticeFlag = await configService.getFeatureFlag(
FeatureFlag.NewDeviceVerificationTemporaryDismiss,
);
const permNoticeFlag = await configService.getFeatureFlag(
FeatureFlag.NewDeviceVerificationPermanentDismiss,
);
if (!tempNoticeFlag && !permNoticeFlag) {
return true;
}
const currentAcct$: Observable<Account | null> = accountService.activeAccount$;
const currentAcct = await firstValueFrom(currentAcct$);
if (!currentAcct) {
return router.createUrlTree(["/login"]);
}
// Currently used by the auth recovery login flow and will get cleaned up in PM-18485.
if (await firstValueFrom(newDeviceVerificationNoticeService.skipState$(currentAcct.id))) {
return true;
}
try {
const isSelfHosted = platformUtilsService.isSelfHost();
const userIsSSOUser = await ssoAppliesToUser(
userVerificationService,
vaultProfileService,
currentAcct.id,
);
const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
vaultProfileService,
currentAcct.id,
);
// When any of the following are true, the device verification notice is
// not applicable for the user. When the user has *not* logged in with their
// master password, assume they logged in with SSO.
if (has2FAEnabled || isSelfHosted || userIsSSOUser || isProfileLessThanWeekOld) {
return true;
}
} catch {
// Skip showing the notice if there was a problem determining applicability
// The most likely problem to occur is the user not having a network connection
return true;
}
const userItems$ = newDeviceVerificationNoticeService.noticeState$(currentAcct.id);
const userItems = await firstValueFrom(userItems$);
// Show the notice when:
// - The temp notice flag is enabled
// - The user hasn't dismissed the notice or the user dismissed it more than 7 days ago
if (
tempNoticeFlag &&
(!userItems?.last_dismissal || isMoreThan7DaysAgo(userItems?.last_dismissal))
) {
return router.createUrlTree(["/new-device-notice"]);
}
// Show the notice when:
// - The permanent notice flag is enabled
// - The user hasn't dismissed the notice
if (permNoticeFlag && !userItems?.permanent_dismissal) {
return router.createUrlTree(["/new-device-notice"]);
}
return true;
};
/** Returns true has one 2FA provider enabled */
async function hasATwoFactorProviderEnabled(
vaultProfileService: VaultProfileService,
userId: string,
): Promise<boolean> {
return vaultProfileService.getProfileTwoFactorEnabled(userId);
}
/** Returns true when the user's profile is less than a week old */
async function profileIsLessThanWeekOld(
vaultProfileService: VaultProfileService,
userId: string,
): Promise<boolean> {
const creationDate = await vaultProfileService.getProfileCreationDate(userId);
return !isMoreThan7DaysAgo(creationDate);
}
/**
* Returns true when either:
* - The user is SSO bound to an organization and is not an Admin or Owner
* - The user is an Admin or Owner of an organization with SSO bound and has not logged in with their master password
*
* NOTE: There are edge cases where this does not satisfy the original requirement of showing the notice to
* users who are subject to the SSO required policy. When Owners and Admins log in with their MP they will see the notice
* when they log in with SSO they will not. This is a concession made because the original logic references policies would not work for TDE users.
* When this guard is run for those users a sync hasn't occurred and thus the policies are not available.
*/
async function ssoAppliesToUser(
userVerificationService: UserVerificationService,
vaultProfileService: VaultProfileService,
userId: string,
) {
const userSSOBound = await vaultProfileService.getUserSSOBound(userId);
const userSSOBoundAdminOwner = await vaultProfileService.getUserSSOBoundAdminOwner(userId);
const userLoggedInWithMP = await userLoggedInWithMasterPassword(userVerificationService, userId);
const nonOwnerAdminSsoUser = userSSOBound && !userSSOBoundAdminOwner;
const ssoAdminOwnerLoggedInWithMP = userSSOBoundAdminOwner && !userLoggedInWithMP;
return nonOwnerAdminSsoUser || ssoAdminOwnerLoggedInWithMP;
}
/**
* Returns true when the user logged in with their master password.
*/
async function userLoggedInWithMasterPassword(
userVerificationService: UserVerificationService,
userId: string,
) {
return userVerificationService.hasMasterPasswordAndMasterKeyHash(userId);
}
/** Returns the true when the date given is older than 7 days */
function isMoreThan7DaysAgo(date?: string | Date): boolean {
if (!date) {
return false;
}
const inputDate = new Date(date).getTime();
const today = new Date().getTime();
const differenceInMS = today - inputDate;
const msInADay = 1000 * 60 * 60 * 24;
const differenceInDays = Math.round(differenceInMS / msInADay);
return differenceInDays > 7;
}

View File

@@ -68,94 +68,4 @@ describe("VaultProfileService", () => {
expect(getProfile).not.toHaveBeenCalled();
});
});
describe("getProfileTwoFactorEnabled", () => {
it("calls `getProfile` when stored 2FA property is not stored", async () => {
expect(service["profile2FAEnabled"]).toBeNull();
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
expect(twoFactorEnabled).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("calls `getProfile` when stored profile id does not match", async () => {
service["profile2FAEnabled"] = false;
service["userId"] = "old-user-id";
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
expect(twoFactorEnabled).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("does not call `getProfile` when 2FA property is already stored", async () => {
service["profile2FAEnabled"] = false;
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
expect(twoFactorEnabled).toBe(false);
expect(getProfile).not.toHaveBeenCalled();
});
});
describe("getUserSSOBound", () => {
it("calls `getProfile` when stored ssoBound property is not stored", async () => {
expect(service["userIsSsoBound"]).toBeNull();
const userIsSsoBound = await service.getUserSSOBound(userId);
expect(userIsSsoBound).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("calls `getProfile` when stored profile id does not match", async () => {
service["userIsSsoBound"] = false;
service["userId"] = "old-user-id";
const userIsSsoBound = await service.getUserSSOBound(userId);
expect(userIsSsoBound).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("does not call `getProfile` when ssoBound property is already stored", async () => {
service["userIsSsoBound"] = false;
const userIsSsoBound = await service.getUserSSOBound(userId);
expect(userIsSsoBound).toBe(false);
expect(getProfile).not.toHaveBeenCalled();
});
});
describe("getUserSSOBoundAdminOwner", () => {
it("calls `getProfile` when stored userIsSsoBoundAdminOwner property is not stored", async () => {
expect(service["userIsSsoBoundAdminOwner"]).toBeNull();
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
expect(userIsSsoBoundAdminOwner).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("calls `getProfile` when stored profile id does not match", async () => {
service["userIsSsoBoundAdminOwner"] = false;
service["userId"] = "old-user-id";
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
expect(userIsSsoBoundAdminOwner).toBe(true);
expect(getProfile).toHaveBeenCalled();
});
it("does not call `getProfile` when userIsSsoBoundAdminOwner property is already stored", async () => {
service["userIsSsoBoundAdminOwner"] = false;
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
expect(userIsSsoBoundAdminOwner).toBe(false);
expect(getProfile).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,18 +1,13 @@
import { Injectable, inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
@Injectable({
providedIn: "root",
})
/**
* Class to provide profile level details without having to call the API each time.
* NOTE: This is a temporary service and can be replaced once the `UnauthenticatedExtensionUIRefresh` flag goes live.
* The `UnauthenticatedExtensionUIRefresh` introduces a sync that takes place upon logging in. These details can then
* be added to account object and retrieved from there.
* TODO: PM-16202
* Class to provide profile level details to vault entities without having to call the API each time.
*/
export class VaultProfileService {
private apiService = inject(ApiService);
@@ -22,15 +17,6 @@ export class VaultProfileService {
/** Profile creation stored as a string. */
private profileCreatedDate: string | null = null;
/** True when 2FA is enabled on the profile. */
private profile2FAEnabled: boolean | null = null;
/** True when ssoBound is true for any of the users organizations */
private userIsSsoBound: boolean | null = null;
/** True when the user is an admin or owner of the ssoBound organization */
private userIsSsoBoundAdminOwner: boolean | null = null;
/**
* Returns the creation date of the profile.
* Note: `Date`s are mutable in JS, creating a new
@@ -46,56 +32,11 @@ export class VaultProfileService {
return new Date(profile.creationDate);
}
/**
* Returns whether there is a 2FA provider on the profile.
*/
async getProfileTwoFactorEnabled(userId: string): Promise<boolean> {
if (this.profile2FAEnabled !== null && userId === this.userId) {
return Promise.resolve(this.profile2FAEnabled);
}
const profile = await this.fetchAndCacheProfile();
return profile.twoFactorEnabled;
}
/**
* Returns whether the user logs in with SSO for any organization.
*/
async getUserSSOBound(userId: string): Promise<boolean> {
if (this.userIsSsoBound !== null && userId === this.userId) {
return Promise.resolve(this.userIsSsoBound);
}
await this.fetchAndCacheProfile();
return !!this.userIsSsoBound;
}
/**
* Returns true when the user is an Admin or Owner of an organization with `ssoBound` true.
*/
async getUserSSOBoundAdminOwner(userId: string): Promise<boolean> {
if (this.userIsSsoBoundAdminOwner !== null && userId === this.userId) {
return Promise.resolve(this.userIsSsoBoundAdminOwner);
}
await this.fetchAndCacheProfile();
return !!this.userIsSsoBoundAdminOwner;
}
private async fetchAndCacheProfile(): Promise<ProfileResponse> {
const profile = await this.apiService.getProfile();
this.userId = profile.id;
this.profileCreatedDate = profile.creationDate;
this.profile2FAEnabled = profile.twoFactorEnabled;
const ssoBoundOrg = profile.organizations.find((org) => org.ssoBound);
this.userIsSsoBound = !!ssoBoundOrg;
this.userIsSsoBoundAdminOwner =
ssoBoundOrg?.type === OrganizationUserType.Admin ||
ssoBoundOrg?.type === OrganizationUserType.Owner;
return profile;
}