mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 17:43:22 +00:00
Merge branch 'main' into vault/PM-12423
This commit is contained in:
@@ -1475,7 +1475,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: LoginSuccessHandlerService,
|
||||
useClass: DefaultLoginSuccessHandlerService,
|
||||
deps: [SyncService, UserAsymmetricKeysRegenerationService],
|
||||
deps: [SyncService, UserAsymmetricKeysRegenerationService, LoginEmailService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaskService,
|
||||
|
||||
@@ -809,11 +809,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation(userId: UserId) {
|
||||
if (this.flow === Flow.StandardAuthRequest) {
|
||||
// Only need to set remembered email on standard login with auth req flow
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(userId);
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@
|
||||
|
||||
<!-- Remember Email input -->
|
||||
<bit-form-control>
|
||||
<input type="checkbox" formControlName="rememberEmail" bitCheckbox />
|
||||
<input
|
||||
type="checkbox"
|
||||
formControlName="rememberEmail"
|
||||
(input)="onRememberEmailInput($event)"
|
||||
bitCheckbox
|
||||
/>
|
||||
<bit-label>{{ "rememberEmail" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
@@ -39,18 +44,18 @@
|
||||
|
||||
<div class="tw-text-center">{{ "or" | i18n }}</div>
|
||||
|
||||
<!-- Link to Login with Passkey page -->
|
||||
<!-- Button to Login with Passkey -->
|
||||
<ng-container *ngIf="isLoginWithPasskeySupported()">
|
||||
<a
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
linkType="primary"
|
||||
routerLink="/login-with-passkey"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
buttonType="secondary"
|
||||
(click)="handleLoginWithPasskeyClick()"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1"></i>
|
||||
{{ "logInWithPasskey" | i18n }}
|
||||
</a>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Button to Login with SSO -->
|
||||
|
||||
@@ -148,6 +148,62 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private async defaultOnInit(): Promise<void> {
|
||||
let paramEmailIsSet = false;
|
||||
|
||||
const params = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
|
||||
if (params) {
|
||||
const qParamsEmail = params.email;
|
||||
|
||||
// If there is an email in the query params, set that email as the form field value
|
||||
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
|
||||
this.formGroup.controls.email.setValue(qParamsEmail);
|
||||
paramEmailIsSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no params or no email in the query params, loadEmailSettings from state
|
||||
if (!paramEmailIsSet) {
|
||||
await this.loadRememberedEmail();
|
||||
}
|
||||
|
||||
// Check to see if the device is known so that we can show the Login with Device option
|
||||
if (this.emailFormControl.value) {
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
}
|
||||
|
||||
// Backup check to handle unknown case where activatedRoute is not available
|
||||
// This shouldn't happen under normal circumstances
|
||||
if (!this.activatedRoute) {
|
||||
await this.loadRememberedEmail();
|
||||
}
|
||||
}
|
||||
|
||||
private async desktopOnInit(): Promise<void> {
|
||||
// TODO: refactor to not use deprecated broadcaster service.
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
||||
this.ngZone.run(() => {
|
||||
switch (message.command) {
|
||||
case "windowIsFocused":
|
||||
if (this.deferFocus === null) {
|
||||
this.deferFocus = !message.windowIsFocused;
|
||||
if (!this.deferFocus) {
|
||||
this.focusInput();
|
||||
}
|
||||
} else if (this.deferFocus && message.windowIsFocused) {
|
||||
this.focusInput();
|
||||
this.deferFocus = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.messagingService.send("getWindowIsFocused");
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
if (this.clientType === ClientType.Desktop) {
|
||||
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
|
||||
@@ -172,7 +228,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
try {
|
||||
const authResult = await this.loginStrategyService.logIn(credentials);
|
||||
|
||||
await this.saveEmailSettings();
|
||||
await this.handleAuthResult(authResult);
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
@@ -250,7 +305,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
// User logged in successfully so execute side effects
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
// Determine where to send the user next
|
||||
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
@@ -288,7 +342,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the master password meets the enforced policy requirements
|
||||
* and if the user is required to change their password.
|
||||
@@ -344,16 +397,9 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveEmailSettings();
|
||||
await this.router.navigate(["/login-with-device"]);
|
||||
}
|
||||
|
||||
protected async validateEmail(): Promise<boolean> {
|
||||
this.formGroup.controls.email.markAsTouched();
|
||||
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
|
||||
return this.formGroup.controls.email.valid;
|
||||
}
|
||||
|
||||
protected async toggleLoginUiState(value: LoginUiState): Promise<void> {
|
||||
this.loginUiState = value;
|
||||
|
||||
@@ -399,37 +445,14 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the email value from the input field.
|
||||
* @param event The event object from the input field.
|
||||
*/
|
||||
onEmailInput(event: Event) {
|
||||
const emailInput = event.target as HTMLInputElement;
|
||||
this.formGroup.controls.email.setValue(emailInput.value);
|
||||
this.loginEmailService.setLoginEmail(emailInput.value);
|
||||
}
|
||||
|
||||
isLoginWithPasskeySupported() {
|
||||
return this.loginComponentService.isLoginWithPasskeySupported();
|
||||
}
|
||||
|
||||
protected async goToHint(): Promise<void> {
|
||||
await this.saveEmailSettings();
|
||||
await this.router.navigateByUrl("/hint");
|
||||
}
|
||||
|
||||
protected async saveEmailSettings(): Promise<void> {
|
||||
const email = this.formGroup.value.email;
|
||||
if (!email) {
|
||||
this.logService.error("Email is required to save email settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loginEmailService.setLoginEmail(email);
|
||||
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false);
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue button clicked (or enter key pressed).
|
||||
* Adds the login url to the browser's history so that the back button can be used to go back to the email entry state.
|
||||
@@ -445,13 +468,44 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
* Continue to the master password entry state (only if email is validated)
|
||||
*/
|
||||
protected async continue(): Promise<void> {
|
||||
const isEmailValid = await this.validateEmail();
|
||||
const isEmailValid = this.validateEmail();
|
||||
|
||||
if (isEmailValid) {
|
||||
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Login with Passkey button click.
|
||||
* We need a handler here in order to persist the remember email selection to state before routing.
|
||||
* @param event - The event object.
|
||||
*/
|
||||
async handleLoginWithPasskeyClick() {
|
||||
await this.router.navigate(["/login-with-passkey"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SSO button click.
|
||||
* @param event - The event object.
|
||||
*/
|
||||
async handleSsoClick() {
|
||||
// Make sure the email is valid
|
||||
const isEmailValid = this.validateEmail();
|
||||
if (!isEmailValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the email is not empty, for type safety
|
||||
const email = this.formGroup.value.email;
|
||||
if (!email) {
|
||||
this.logService.error("Email is required for SSO");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the user to SSO, either through routing or through redirecting to the web app
|
||||
await this.loginComponentService.redirectToSsoLogin(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call to check if the device is known.
|
||||
* Known means that the user has logged in with this device before.
|
||||
@@ -473,23 +527,17 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEmailSettings(): Promise<void> {
|
||||
// Try to load the email from memory first
|
||||
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
const rememberEmail = this.loginEmailService.getRememberEmail();
|
||||
|
||||
if (email) {
|
||||
this.formGroup.controls.email.setValue(email);
|
||||
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
|
||||
/**
|
||||
* Check to see if the user has remembered an email on the current device.
|
||||
* If so, set the email in the form field and set rememberEmail to true. If not, set rememberEmail to false.
|
||||
*/
|
||||
private async loadRememberedEmail(): Promise<void> {
|
||||
const storedEmail = await firstValueFrom(this.loginEmailService.rememberedEmail$);
|
||||
if (storedEmail) {
|
||||
this.formGroup.controls.email.setValue(storedEmail);
|
||||
this.formGroup.controls.rememberEmail.setValue(true);
|
||||
} else {
|
||||
// If there is no email in memory, check for a storedEmail on disk
|
||||
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
|
||||
|
||||
if (storedEmail) {
|
||||
this.formGroup.controls.email.setValue(storedEmail);
|
||||
// If there is a storedEmail, rememberEmail defaults to true
|
||||
this.formGroup.controls.rememberEmail.setValue(true);
|
||||
}
|
||||
this.formGroup.controls.rememberEmail.setValue(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,62 +551,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
?.focus();
|
||||
}
|
||||
|
||||
private async defaultOnInit(): Promise<void> {
|
||||
let paramEmailIsSet = false;
|
||||
|
||||
const params = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
|
||||
if (params) {
|
||||
const qParamsEmail = params.email;
|
||||
|
||||
// If there is an email in the query params, set that email as the form field value
|
||||
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
|
||||
this.formGroup.controls.email.setValue(qParamsEmail);
|
||||
paramEmailIsSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no params or no email in the query params, loadEmailSettings from state
|
||||
if (!paramEmailIsSet) {
|
||||
await this.loadEmailSettings();
|
||||
}
|
||||
|
||||
// Check to see if the device is known so that we can show the Login with Device option
|
||||
if (this.emailFormControl.value) {
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
}
|
||||
|
||||
// Backup check to handle unknown case where activatedRoute is not available
|
||||
// This shouldn't happen under normal circumstances
|
||||
if (!this.activatedRoute) {
|
||||
await this.loadEmailSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private async desktopOnInit(): Promise<void> {
|
||||
// TODO: refactor to not use deprecated broadcaster service.
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
||||
this.ngZone.run(() => {
|
||||
switch (message.command) {
|
||||
case "windowIsFocused":
|
||||
if (this.deferFocus === null) {
|
||||
this.deferFocus = !message.windowIsFocused;
|
||||
if (!this.deferFocus) {
|
||||
this.focusInput();
|
||||
}
|
||||
} else if (this.deferFocus && message.windowIsFocused) {
|
||||
this.focusInput();
|
||||
this.deferFocus = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.messagingService.send("getWindowIsFocused");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if the back button should be shown.
|
||||
* @returns true if the back button should be shown.
|
||||
@@ -597,27 +589,56 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the SSO button click.
|
||||
* Validates the email and displays any validation errors.
|
||||
* @returns true if the email is valid, false otherwise.
|
||||
*/
|
||||
async handleSsoClick() {
|
||||
const email = this.formGroup.value.email;
|
||||
protected validateEmail(): boolean {
|
||||
this.formGroup.controls.email.markAsTouched();
|
||||
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
|
||||
return this.formGroup.controls.email.valid;
|
||||
}
|
||||
|
||||
// Make sure the email is valid
|
||||
const isEmailValid = await this.validateEmail();
|
||||
if (!isEmailValid) {
|
||||
return;
|
||||
/**
|
||||
* Persist the entered email address and the user's choice to remember it to state.
|
||||
*/
|
||||
private async persistEmailIfValid(): Promise<void> {
|
||||
if (this.formGroup.controls.email.valid) {
|
||||
const email = this.formGroup.value.email;
|
||||
const rememberEmail = this.formGroup.value.rememberEmail ?? false;
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
await this.loginEmailService.setLoginEmail(email);
|
||||
await this.loginEmailService.setRememberedEmailChoice(email, rememberEmail);
|
||||
} else {
|
||||
await this.loginEmailService.clearLoginEmail();
|
||||
await this.loginEmailService.clearRememberedEmail();
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the email is not empty, for type safety
|
||||
if (!email) {
|
||||
this.logService.error("Email is required for SSO");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Set the email value from the input field and persists to state if valid.
|
||||
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
|
||||
* the user submits. This is because currently our validation errors are shown below the input fields, and
|
||||
* displaying them causes the screen to "jump".
|
||||
* @param event The event object from the input field.
|
||||
*/
|
||||
async onEmailInput(event: Event) {
|
||||
const emailInput = event.target as HTMLInputElement;
|
||||
this.formGroup.controls.email.setValue(emailInput.value);
|
||||
await this.persistEmailIfValid();
|
||||
}
|
||||
|
||||
// Save the email configuration for the login component
|
||||
await this.saveEmailSettings();
|
||||
|
||||
// Send the user to SSO, either through routing or through redirecting to the web app
|
||||
await this.loginComponentService.redirectToSsoLogin(email);
|
||||
/**
|
||||
* Set the Remember Email value from the input field and persists to state if valid.
|
||||
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
|
||||
* the user submits. This is because currently our validation errors are shown below the input fields, and
|
||||
* displaying them causes the screen to "jump".
|
||||
* @param event The event object from the input field.
|
||||
*/
|
||||
async onRememberEmailInput(event: Event) {
|
||||
const rememberEmailInput = event.target as HTMLInputElement;
|
||||
this.formGroup.controls.rememberEmail.setValue(rememberEmailInput.checked);
|
||||
await this.persistEmailIfValid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
LinkModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
|
||||
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
|
||||
|
||||
/**
|
||||
@@ -60,8 +59,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private syncService: SyncService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -143,9 +141,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
this.loginSuccessHandlerService.run(authResult.userId);
|
||||
|
||||
// If verification succeeds, navigate to vault
|
||||
await this.router.navigate(["/vault"]);
|
||||
|
||||
@@ -254,19 +254,6 @@ describe("TwoFactorAuthComponent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("calls loginEmailService.clearValues() when login is successful", async () => {
|
||||
// Arrange
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
// spy on loginEmailService.clearValues
|
||||
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
|
||||
|
||||
// Act
|
||||
await component.submit(token, remember);
|
||||
|
||||
// Assert
|
||||
expect(clearValuesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Set Master Password scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const authResult = new AuthResult();
|
||||
|
||||
@@ -382,7 +382,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
// User is fully logged in so handle any post login logic before executing navigation
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows
|
||||
// - TDE login decryption options component
|
||||
|
||||
@@ -1,43 +1,34 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class LoginEmailServiceAbstraction {
|
||||
/**
|
||||
* An observable that monitors the loginEmail in memory.
|
||||
* An observable that monitors the loginEmail.
|
||||
* The loginEmail is the email that is being used in the current login process.
|
||||
*/
|
||||
loginEmail$: Observable<string | null>;
|
||||
abstract loginEmail$: Observable<string | null>;
|
||||
/**
|
||||
* An observable that monitors the storedEmail on disk.
|
||||
* An observable that monitors the remembered email.
|
||||
* This will return null if an account is being added.
|
||||
*/
|
||||
storedEmail$: Observable<string | null>;
|
||||
abstract rememberedEmail$: Observable<string | null>;
|
||||
/**
|
||||
* Sets the loginEmail in memory.
|
||||
* The loginEmail is the email that is being used in the current login process.
|
||||
* Consumed through `loginEmail$` observable.
|
||||
*/
|
||||
setLoginEmail: (email: string) => Promise<void>;
|
||||
abstract setLoginEmail: (email: string) => Promise<void>;
|
||||
/**
|
||||
* Gets from memory whether or not the email should be stored on disk when `saveEmailSettings` is called.
|
||||
* @returns A boolean stating whether or not the email should be stored on disk.
|
||||
* Persist the user's choice of whether to remember their email on subsequent login attempts.
|
||||
* Consumed through `rememberedEmail$` observable.
|
||||
*/
|
||||
getRememberEmail: () => boolean;
|
||||
abstract setRememberedEmailChoice: (email: string, remember: boolean) => Promise<void>;
|
||||
/**
|
||||
* Sets in memory whether or not the email should be stored on disk when `saveEmailSettings` is called.
|
||||
* Clears the in-progress login email, to be used after a successful login.
|
||||
*/
|
||||
setRememberEmail: (value: boolean) => void;
|
||||
abstract clearLoginEmail: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the email and rememberEmail properties in memory to null.
|
||||
* Clears the remembered email.
|
||||
*/
|
||||
clearValues: () => void;
|
||||
/**
|
||||
* Saves or clears the email on disk
|
||||
* - If an account is being added, only changes the stored email when rememberEmail is true.
|
||||
* - If rememberEmail is true, sets the email on disk to the current email.
|
||||
* - If rememberEmail is false, sets the email on disk to null.
|
||||
* Always clears the email and rememberEmail properties from memory.
|
||||
* @returns A promise that resolves once the email settings are saved.
|
||||
*/
|
||||
saveEmailSettings: () => Promise<void>;
|
||||
abstract clearRememberedEmail: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { LoginEmailService, STORED_EMAIL } from "./login-email.service";
|
||||
|
||||
describe("LoginEmailService", () => {
|
||||
let sut: LoginEmailService;
|
||||
let service: LoginEmailService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let authService: MockProxy<AuthService>;
|
||||
@@ -34,119 +34,93 @@ describe("LoginEmailService", () => {
|
||||
mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
|
||||
authService.authStatuses$ = mockAuthStatuses$;
|
||||
|
||||
sut = new LoginEmailService(accountService, authService, stateProvider);
|
||||
service = new LoginEmailService(accountService, authService, stateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("storedEmail$", () => {
|
||||
it("returns the stored email when not adding an account", async () => {
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
describe("rememberedEmail$", () => {
|
||||
it("returns the remembered email when not adding an account", async () => {
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
const result = await firstValueFrom(sut.storedEmail$);
|
||||
await service.setRememberedEmailChoice(testEmail, true);
|
||||
|
||||
expect(result).toEqual("userEmail@bitwarden.com");
|
||||
const result = await firstValueFrom(service.rememberedEmail$);
|
||||
|
||||
expect(result).toEqual(testEmail);
|
||||
});
|
||||
|
||||
it("returns the stored email when not adding an account and the user has just logged in", async () => {
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
it("returns the remembered email when not adding an account and the user has just logged in", async () => {
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
await service.setRememberedEmailChoice(testEmail, true);
|
||||
|
||||
mockAuthStatuses$.next({ [userId]: AuthenticationStatus.Unlocked });
|
||||
// account service already initialized with userId as active user
|
||||
|
||||
const result = await firstValueFrom(sut.storedEmail$);
|
||||
const result = await firstValueFrom(service.rememberedEmail$);
|
||||
|
||||
expect(result).toEqual("userEmail@bitwarden.com");
|
||||
expect(result).toEqual(testEmail);
|
||||
});
|
||||
|
||||
it("returns null when adding an account", async () => {
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
await service.setRememberedEmailChoice(testEmail, true);
|
||||
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(sut.storedEmail$);
|
||||
const result = await firstValueFrom(service.rememberedEmail$);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveEmailSettings", () => {
|
||||
it("saves the email when not adding an account", async () => {
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
describe("setRememberedEmailChoice", () => {
|
||||
it("sets the remembered email when remember is true", async () => {
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
await service.setRememberedEmailChoice(testEmail, true);
|
||||
|
||||
const result = await firstValueFrom(storedEmailState.state$);
|
||||
|
||||
expect(result).toEqual("userEmail@bitwarden.com");
|
||||
expect(result).toEqual(testEmail);
|
||||
});
|
||||
|
||||
it("clears the email when not adding an account and rememberEmail is false", async () => {
|
||||
it("clears the remembered email when remember is false", async () => {
|
||||
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
|
||||
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(false);
|
||||
await sut.saveEmailSettings();
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
await service.setRememberedEmailChoice(testEmail, false);
|
||||
|
||||
const result = await firstValueFrom(storedEmailState.state$);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves the email when adding an account", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
|
||||
});
|
||||
describe("setLoginEmail", () => {
|
||||
it("sets the login email", async () => {
|
||||
const testEmail = "test@bitwarden.com";
|
||||
await service.setLoginEmail(testEmail);
|
||||
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
|
||||
const result = await firstValueFrom(storedEmailState.state$);
|
||||
|
||||
expect(result).toEqual("userEmail@bitwarden.com");
|
||||
expect(await firstValueFrom(service.loginEmail$)).toEqual(testEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not clear the email when adding an account and rememberEmail is false", async () => {
|
||||
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
|
||||
describe("clearLoginEmail", () => {
|
||||
it("clears the login email", async () => {
|
||||
const testEmail = "test@bitwarden.com";
|
||||
await service.setLoginEmail(testEmail);
|
||||
await service.clearLoginEmail();
|
||||
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
|
||||
});
|
||||
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(false);
|
||||
await sut.saveEmailSettings();
|
||||
|
||||
const result = await firstValueFrom(storedEmailState.state$);
|
||||
|
||||
// result should not be null
|
||||
expect(result).toEqual("initialEmail@bitwarden.com");
|
||||
});
|
||||
|
||||
it("does not clear the email and rememberEmail after saving", async () => {
|
||||
// Browser uses these values to maintain the email between login and 2fa components so
|
||||
// we do not want to clear them too early.
|
||||
await sut.setLoginEmail("userEmail@bitwarden.com");
|
||||
sut.setRememberEmail(true);
|
||||
await sut.saveEmailSettings();
|
||||
|
||||
const result = await firstValueFrom(sut.loginEmail$);
|
||||
|
||||
expect(result).toBe("userEmail@bitwarden.com");
|
||||
expect(await firstValueFrom(service.loginEmail$)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -26,8 +24,6 @@ export const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedE
|
||||
});
|
||||
|
||||
export class LoginEmailService implements LoginEmailServiceAbstraction {
|
||||
private rememberEmail: boolean;
|
||||
|
||||
// True if an account is currently being added through account switching
|
||||
private readonly addingAccount$: Observable<boolean>;
|
||||
|
||||
@@ -35,7 +31,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
|
||||
loginEmail$: Observable<string | null>;
|
||||
|
||||
private readonly storedEmailState: GlobalState<string>;
|
||||
storedEmail$: Observable<string | null>;
|
||||
rememberedEmail$: Observable<string | null>;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
@@ -60,7 +56,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
|
||||
|
||||
this.loginEmail$ = this.loginEmailState.state$;
|
||||
|
||||
this.storedEmail$ = this.storedEmailState.state$.pipe(
|
||||
this.rememberedEmail$ = this.storedEmailState.state$.pipe(
|
||||
switchMap(async (storedEmail) => {
|
||||
// When adding an account, we don't show the stored email
|
||||
if (await firstValueFrom(this.addingAccount$)) {
|
||||
@@ -71,44 +67,32 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
/** Sets the login email in memory.
|
||||
* The login email is the email that is being used in the current login process.
|
||||
*/
|
||||
async setLoginEmail(email: string) {
|
||||
await this.loginEmailState.update((_) => email);
|
||||
}
|
||||
|
||||
getRememberEmail() {
|
||||
return this.rememberEmail;
|
||||
/**
|
||||
* Clears the in-progress login email from state.
|
||||
* Note: Only clear on successful login or you are sure they are not needed.
|
||||
* The extension client uses these values to maintain the email between login and 2fa components so
|
||||
* we do not want to clear them too early.
|
||||
*/
|
||||
async clearLoginEmail() {
|
||||
await this.loginEmailState.update((_) => null);
|
||||
}
|
||||
|
||||
setRememberEmail(value: boolean) {
|
||||
this.rememberEmail = value ?? false;
|
||||
async setRememberedEmailChoice(email: string, remember: boolean): Promise<void> {
|
||||
if (remember) {
|
||||
await this.storedEmailState.update((_) => email);
|
||||
} else {
|
||||
await this.storedEmailState.update((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: only clear values on successful login or you are sure they are not needed.
|
||||
// Browser uses these values to maintain the email between login and 2fa components so
|
||||
// we do not want to clear them too early.
|
||||
async clearValues() {
|
||||
await this.setLoginEmail(null);
|
||||
this.rememberEmail = false;
|
||||
}
|
||||
|
||||
async saveEmailSettings() {
|
||||
const addingAccount = await firstValueFrom(this.addingAccount$);
|
||||
const email = await firstValueFrom(this.loginEmail$);
|
||||
|
||||
await this.storedEmailState.update((storedEmail) => {
|
||||
// If we're adding an account, only overwrite the stored email when rememberEmail is true
|
||||
if (addingAccount) {
|
||||
if (this.rememberEmail) {
|
||||
return email;
|
||||
}
|
||||
return storedEmail;
|
||||
}
|
||||
|
||||
// Saving with rememberEmail set to false will clear the stored email
|
||||
if (this.rememberEmail) {
|
||||
return email;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
async clearRememberedEmail(): Promise<void> {
|
||||
await this.storedEmailState.update((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
|
||||
import { LoginSuccessHandlerService } from "../../abstractions/login-success-handler.service";
|
||||
import { LoginEmailService } from "../login-email/login-email.service";
|
||||
|
||||
export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerService {
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private loginEmailService: LoginEmailService,
|
||||
) {}
|
||||
async run(userId: UserId): Promise<void> {
|
||||
await this.syncService.fullSync(true);
|
||||
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
||||
await this.loginEmailService.clearLoginEmail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export enum FeatureFlag {
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
UserKeyRotationV2 = "userkey-rotation-v2",
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
PM17987_BlockType0 = "pm-17987-block-type-0",
|
||||
|
||||
/* Tools */
|
||||
ItemShare = "item-share",
|
||||
@@ -56,6 +57,9 @@ export enum FeatureFlag {
|
||||
SecurityTasks = "security-tasks",
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -120,6 +124,10 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.UserKeyRotationV2]: FALSE,
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.PM17987_BlockType0]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -19,10 +19,17 @@ import {
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
private blockType0: boolean = DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0];
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
@@ -31,7 +38,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
|
||||
// Handle updating private properties to turn on/off feature flags.
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
return;
|
||||
this.blockType0 = getFeatureFlagValue(newConfig, FeatureFlag.PM17987_BlockType0);
|
||||
}
|
||||
|
||||
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
@@ -39,6 +46,12 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -70,6 +83,12 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encValue = await this.aesEncrypt(plainValue, innerKey);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
@@ -26,17 +28,65 @@ describe("EncryptService", () => {
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("updates internal flag with default value when not present in config", () => {
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(
|
||||
DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0],
|
||||
);
|
||||
});
|
||||
|
||||
test.each([true, false])("updates internal flag with value in config", (expectedValue) => {
|
||||
newConfig.featureStates = { [FeatureFlag.PM17987_BlockType0]: expectedValue };
|
||||
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.encrypt(null, null)).rejects.toThrow(
|
||||
"No encryption key provided.",
|
||||
);
|
||||
});
|
||||
it("returns null if no data is provided", async () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
|
||||
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(null!, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
const plainValue = "data";
|
||||
await expect(encryptService.encrypt(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null if no data is provided with valid key", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const actual = await encryptService.encrypt(null, key);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const plainValue = "data";
|
||||
@@ -53,6 +103,7 @@ describe("EncryptService", () => {
|
||||
expect(Utils.fromB64ToArray(result.data).length).toEqual(4);
|
||||
expect(Utils.fromB64ToArray(result.iv).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc_HmacSha256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = "data";
|
||||
@@ -90,6 +141,21 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if type 0 key provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts data with provided Aes256Cbc key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
|
||||
@@ -189,6 +189,33 @@ describe("IntegrationContext", () => {
|
||||
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the hostname when extractHostname is true", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||
|
||||
const result = context.website(
|
||||
{ website: "https://www.example.com/path" },
|
||||
{ extractHostname: true },
|
||||
);
|
||||
|
||||
expect(result).toBe("www.example.com");
|
||||
});
|
||||
|
||||
it("falls back to the full URL when Utils.getHost cannot extract the hostname", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||
|
||||
const result = context.website({ website: "invalid-url" }, { extractHostname: true });
|
||||
|
||||
expect(result).toBe("invalid-url");
|
||||
});
|
||||
|
||||
it("truncates the website to maxLength", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||
|
||||
const result = context.website({ website: "www.example.com" }, { maxLength: 3 });
|
||||
|
||||
expect(result).toBe("www");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatedBy", () => {
|
||||
@@ -211,5 +238,15 @@ describe("IntegrationContext", () => {
|
||||
expect(result).toBe("result");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderGeneratedByWithWebsite", "www.example.com");
|
||||
});
|
||||
|
||||
it("truncates generated text to maxLength", () => {
|
||||
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
|
||||
i18n.t.mockReturnValue("This is the result text");
|
||||
|
||||
const result = context.generatedBy({ website: null }, { maxLength: 4 });
|
||||
|
||||
expect(result).toBe("This");
|
||||
expect(i18n.t).toHaveBeenCalledWith("forwarderGeneratedBy", "");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,24 +79,40 @@ export class IntegrationContext<Settings extends object> {
|
||||
|
||||
/** look up the website the integration is working with.
|
||||
* @param request supplies information about the state of the extension site
|
||||
* @param options optional parameters
|
||||
* @param options.extractHostname when `true`, tries to extract the hostname from the website URL, returns full URL otherwise
|
||||
* @param options.maxLength limits the length of the return value
|
||||
* @returns The website or an empty string if a website isn't available
|
||||
* @remarks `website` is usually supplied when generating a credential from the vault
|
||||
*/
|
||||
website(request: IntegrationRequest) {
|
||||
return request.website ?? "";
|
||||
website(
|
||||
request: IntegrationRequest,
|
||||
options?: { extractHostname?: boolean; maxLength?: number },
|
||||
) {
|
||||
let url = request.website ?? "";
|
||||
if (options?.extractHostname) {
|
||||
url = Utils.getHost(url) ?? url;
|
||||
}
|
||||
return url.slice(0, options?.maxLength);
|
||||
}
|
||||
|
||||
/** look up localized text indicating Bitwarden requested the forwarding address.
|
||||
* @param request supplies information about the state of the extension site
|
||||
* @param options optional parameters
|
||||
* @param options.extractHostname when `true`, extracts the hostname from the website URL
|
||||
* @param options.maxLength limits the length of the return value
|
||||
* @returns localized text describing a generated forwarding address
|
||||
*/
|
||||
generatedBy(request: IntegrationRequest) {
|
||||
const website = this.website(request);
|
||||
generatedBy(
|
||||
request: IntegrationRequest,
|
||||
options?: { extractHostname?: boolean; maxLength?: number },
|
||||
) {
|
||||
const website = this.website(request, { extractHostname: options?.extractHostname ?? false });
|
||||
|
||||
const descriptionId =
|
||||
website === "" ? "forwarderGeneratedBy" : "forwarderGeneratedByWithWebsite";
|
||||
const description = this.i18n.t(descriptionId, website);
|
||||
|
||||
return description;
|
||||
return description.slice(0, options?.maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
}
|
||||
</span>
|
||||
@if (!hasError) {
|
||||
<ng-content select="bit-hint"></ng-content>
|
||||
<span class="[&_bit-hint]:tw-mt-0 tw-leading-none">
|
||||
<ng-content select="bit-hint"></ng-content>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { ImportResult } from "../models";
|
||||
|
||||
import { BaseImporter } from "./base-importer";
|
||||
|
||||
@@ -16,8 +19,169 @@ class FakeBaseImporter extends BaseImporter {
|
||||
parseXml(data: string): Document {
|
||||
return super.parseXml(data);
|
||||
}
|
||||
|
||||
processFolder(result: ImportResult, folderName: string, addRelationship: boolean = true): void {
|
||||
return super.processFolder(result, folderName, addRelationship);
|
||||
}
|
||||
}
|
||||
|
||||
describe("processFolder method", () => {
|
||||
let result: ImportResult;
|
||||
const importer = new FakeBaseImporter();
|
||||
|
||||
beforeEach(() => {
|
||||
result = {
|
||||
folders: [],
|
||||
folderRelationships: [],
|
||||
collections: [],
|
||||
collectionRelationships: [],
|
||||
ciphers: [],
|
||||
success: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
});
|
||||
|
||||
it("should add a new folder and relationship when folderName is unique", () => {
|
||||
// arrange
|
||||
// a folder exists - but it is not the same as the one we are importing
|
||||
result = {
|
||||
folders: [{ name: "ABC" } as FolderView],
|
||||
folderRelationships: [],
|
||||
collections: [],
|
||||
collectionRelationships: [],
|
||||
ciphers: [{ name: "cipher1", id: "cipher1" } as CipherView],
|
||||
success: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
importer.processFolder(result, "Folder1");
|
||||
|
||||
expect(result.folders).toHaveLength(2);
|
||||
expect(result.folders[0].name).toBe("ABC");
|
||||
expect(result.folders[1].name).toBe("Folder1");
|
||||
expect(result.folderRelationships).toHaveLength(1);
|
||||
expect(result.folderRelationships[0]).toEqual([1, 1]); // cipher1 -> Folder1
|
||||
});
|
||||
|
||||
it("should not add duplicate folders and should add relationships", () => {
|
||||
// setup
|
||||
// folder called "Folder1" already exists
|
||||
result = {
|
||||
folders: [{ name: "Folder1" } as FolderView],
|
||||
folderRelationships: [],
|
||||
collections: [],
|
||||
collectionRelationships: [],
|
||||
ciphers: [{ name: "cipher1", id: "cipher1" } as CipherView],
|
||||
success: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
|
||||
// import an existing folder should not add to the result.folders
|
||||
importer.processFolder(result, "Folder1");
|
||||
|
||||
expect(result.folders).toHaveLength(1);
|
||||
expect(result.folders[0].name).toBe("Folder1");
|
||||
expect(result.folderRelationships).toHaveLength(1);
|
||||
expect(result.folderRelationships[0]).toEqual([1, 0]); // cipher1 -> folder1
|
||||
});
|
||||
|
||||
it("should create parent folders for nested folder names but not duplicates", () => {
|
||||
// arrange
|
||||
result = {
|
||||
folders: [
|
||||
{ name: "Ancestor/Parent/Child" } as FolderView,
|
||||
{ name: "Ancestor" } as FolderView,
|
||||
],
|
||||
folderRelationships: [],
|
||||
collections: [],
|
||||
collectionRelationships: [],
|
||||
ciphers: [{ name: "cipher1", id: "cipher1" } as CipherView],
|
||||
success: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
|
||||
// act
|
||||
// importing an existing folder with a relationship should not change the result.folders
|
||||
// nor should it change the result.folderRelationships
|
||||
importer.processFolder(result, "Ancestor/Parent/Child/Grandchild/GreatGrandchild");
|
||||
|
||||
expect(result.folders).toHaveLength(5);
|
||||
expect(result.folders.map((f) => f.name)).toEqual([
|
||||
"Ancestor/Parent/Child",
|
||||
"Ancestor",
|
||||
"Ancestor/Parent/Child/Grandchild/GreatGrandchild",
|
||||
"Ancestor/Parent/Child/Grandchild",
|
||||
"Ancestor/Parent",
|
||||
]);
|
||||
expect(result.folderRelationships).toHaveLength(1);
|
||||
expect(result.folderRelationships[0]).toEqual([1, 2]); // cipher1 -> grandchild
|
||||
});
|
||||
|
||||
it("should not affect existing relationships", () => {
|
||||
// arrange
|
||||
// "Parent" is a folder with no relationship
|
||||
// "Child" is a folder with 2 ciphers
|
||||
result = {
|
||||
folders: [{ name: "Parent" } as FolderView, { name: "Parent/Child" } as FolderView],
|
||||
folderRelationships: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
],
|
||||
collections: [],
|
||||
collectionRelationships: [],
|
||||
ciphers: [
|
||||
{ name: "cipher1", id: "cipher1" } as CipherView,
|
||||
{ name: "cipher2", id: "cipher2" } as CipherView,
|
||||
{ name: "cipher3", id: "cipher3" } as CipherView,
|
||||
],
|
||||
success: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
|
||||
// act
|
||||
// importing an existing folder with a relationship should not change the result.folders
|
||||
// nor should it change the result.folderRelationships
|
||||
importer.processFolder(result, "Parent/Child/Grandchild");
|
||||
|
||||
expect(result.folders).toHaveLength(3);
|
||||
expect(result.folders.map((f) => f.name)).toEqual([
|
||||
"Parent",
|
||||
"Parent/Child",
|
||||
"Parent/Child/Grandchild",
|
||||
]);
|
||||
expect(result.folderRelationships).toHaveLength(3);
|
||||
expect(result.folderRelationships[0]).toEqual([1, 1]); // cipher1 -> child
|
||||
expect(result.folderRelationships[1]).toEqual([2, 1]); // cipher2 -> child
|
||||
expect(result.folderRelationships[2]).toEqual([3, 2]); // cipher3 -> grandchild
|
||||
});
|
||||
|
||||
it("should not add relationships if addRelationship is false", () => {
|
||||
importer.processFolder(result, "Folder1", false);
|
||||
|
||||
expect(result.folders).toHaveLength(1);
|
||||
expect(result.folders[0].name).toBe("Folder1");
|
||||
expect(result.folderRelationships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should replace backslashes with forward slashes in folder names", () => {
|
||||
importer.processFolder(result, "Parent\\Child\\Grandchild");
|
||||
|
||||
expect(result.folders).toHaveLength(3);
|
||||
expect(result.folders.map((f) => f.name)).toEqual([
|
||||
"Parent/Child/Grandchild",
|
||||
"Parent/Child",
|
||||
"Parent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle empty or null folder names gracefully", () => {
|
||||
importer.processFolder(result, null);
|
||||
importer.processFolder(result, "");
|
||||
|
||||
expect(result.folders).toHaveLength(0);
|
||||
expect(result.folderRelationships).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseImporter class", () => {
|
||||
const importer = new FakeBaseImporter();
|
||||
let cipher: CipherView;
|
||||
|
||||
@@ -366,7 +366,7 @@ export abstract class BaseImporter {
|
||||
|
||||
let folderIndex = result.folders.length;
|
||||
// Replace backslashes with forward slashes, ensuring we create sub-folders
|
||||
folderName = folderName.replace("\\", "/");
|
||||
folderName = folderName.replace(/\\/g, "/");
|
||||
let addFolder = true;
|
||||
|
||||
for (let i = 0; i < result.folders.length; i++) {
|
||||
@@ -387,6 +387,17 @@ export abstract class BaseImporter {
|
||||
if (addRelationship) {
|
||||
result.folderRelationships.push([result.ciphers.length, folderIndex]);
|
||||
}
|
||||
|
||||
// if the folder name is a/b/c/d, we need to create a/b/c and a/b and a
|
||||
const parts = folderName.split("/");
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentName = parts.slice(0, i).join("/") as string;
|
||||
if (result.folders.find((c) => c.name === parentName) == null) {
|
||||
const folder = new FolderView();
|
||||
folder.name = parentName;
|
||||
result.folders.push(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected convertToNoteIfNeeded(cipher: CipherView) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -25,35 +24,11 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer {
|
||||
if (this.organization && !this.isNullOrWhitespace(value.collections)) {
|
||||
const collections = (value.collections as string).split(",");
|
||||
collections.forEach((col) => {
|
||||
let addCollection = true;
|
||||
let collectionIndex = result.collections.length;
|
||||
|
||||
for (let i = 0; i < result.collections.length; i++) {
|
||||
if (result.collections[i].name === col) {
|
||||
addCollection = false;
|
||||
collectionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (addCollection) {
|
||||
const collection = new CollectionView();
|
||||
collection.name = col;
|
||||
result.collections.push(collection);
|
||||
}
|
||||
|
||||
result.collectionRelationships.push([result.ciphers.length, collectionIndex]);
|
||||
|
||||
// if the collection name is a/b/c/d, we need to create a/b/c and a/b and a
|
||||
const parts = col.split("/");
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentCollectionName = parts.slice(0, i).join("/") as string;
|
||||
if (result.collections.find((c) => c.name === parentCollectionName) == null) {
|
||||
const parentCollection = new CollectionView();
|
||||
parentCollection.name = parentCollectionName;
|
||||
result.collections.push(parentCollection);
|
||||
}
|
||||
}
|
||||
// here processFolder is used to create collections
|
||||
// In an Organization folders are converted to collections
|
||||
// see line just before this function terminates
|
||||
// where all folders are turned to collections
|
||||
this.processFolder(result, col);
|
||||
});
|
||||
} else if (!this.organization) {
|
||||
this.processFolder(result, value.folder);
|
||||
@@ -125,6 +100,10 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer {
|
||||
result.ciphers.push(cipher);
|
||||
});
|
||||
|
||||
if (this.organization) {
|
||||
this.moveFoldersToCollections(result);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { testData as TestData } from "../spec-data/keeper-csv/testdata.csv";
|
||||
import {
|
||||
testData as TestData,
|
||||
testDataMultiCollection,
|
||||
} from "../spec-data/keeper-csv/testdata.csv";
|
||||
|
||||
import { KeeperCsvImporter } from "./keeper-csv-importer";
|
||||
|
||||
@@ -121,4 +124,32 @@ describe("Keeper CSV Importer", () => {
|
||||
expect(result.collectionRelationships[1]).toEqual([1, 0]);
|
||||
expect(result.collectionRelationships[2]).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("should create collections tree, with child collections and relationships", async () => {
|
||||
importer.organizationId = Utils.newGuid();
|
||||
const result = await importer.parse(testDataMultiCollection);
|
||||
expect(result != null).toBe(true);
|
||||
|
||||
const collections = result.collections;
|
||||
expect(collections).not.toBeNull();
|
||||
expect(collections.length).toBe(3);
|
||||
|
||||
// collection with the cipher
|
||||
const collections1 = collections.shift();
|
||||
expect(collections1.name).toBe("Foo/Baz/Bar");
|
||||
|
||||
//second level collection
|
||||
const collections2 = collections.shift();
|
||||
expect(collections2.name).toBe("Foo/Baz");
|
||||
|
||||
//third level
|
||||
const collections3 = collections.shift();
|
||||
expect(collections3.name).toBe("Foo");
|
||||
|
||||
// [Cipher, Folder]
|
||||
expect(result.collectionRelationships.length).toBe(3);
|
||||
expect(result.collectionRelationships[0]).toEqual([0, 0]);
|
||||
expect(result.collectionRelationships[1]).toEqual([1, 1]);
|
||||
expect(result.collectionRelationships[2]).toEqual([2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { credentialsData } from "../spec-data/netwrix-csv/login-export.csv";
|
||||
import {
|
||||
credentialsData,
|
||||
credentialsDataWithFolders,
|
||||
} from "../spec-data/netwrix-csv/login-export.csv";
|
||||
|
||||
import { NetwrixPasswordSecureCsvImporter } from "./netwrix-passwordsecure-csv-importer";
|
||||
|
||||
@@ -88,4 +91,18 @@ describe("Netwrix Password Secure CSV Importer", () => {
|
||||
expect(result.collectionRelationships[1]).toEqual([1, 1]);
|
||||
expect(result.collectionRelationships[2]).toEqual([2, 0]);
|
||||
});
|
||||
|
||||
it("should parse multiple collections", async () => {
|
||||
importer.organizationId = Utils.newGuid();
|
||||
const result = await importer.parse(credentialsDataWithFolders);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.collections.length).toBe(3);
|
||||
expect(result.collections[0].name).toBe("folder1/folder2/folder3");
|
||||
expect(result.collections[1].name).toBe("folder1/folder2");
|
||||
expect(result.collections[2].name).toBe("folder1");
|
||||
expect(result.collectionRelationships.length).toBe(1);
|
||||
expect(result.collectionRelationships[0]).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import { ImportResult } from "../../models/import-result";
|
||||
import { dutchHeaders } from "../spec-data/passwordxp-csv/dutch-headers";
|
||||
import { germanHeaders } from "../spec-data/passwordxp-csv/german-headers";
|
||||
import { noFolder } from "../spec-data/passwordxp-csv/no-folder.csv";
|
||||
import { withFolders } from "../spec-data/passwordxp-csv/passwordxp-with-folders.csv";
|
||||
import {
|
||||
withFolders,
|
||||
withMultipleFolders,
|
||||
} from "../spec-data/passwordxp-csv/passwordxp-with-folders.csv";
|
||||
import { withoutFolders } from "../spec-data/passwordxp-csv/passwordxp-without-folders.csv";
|
||||
|
||||
import { PasswordXPCsvImporter } from "./passwordxp-csv-importer";
|
||||
@@ -167,4 +170,22 @@ describe("PasswordXPCsvImporter", () => {
|
||||
expect(collectionRelationship).toEqual([4, 2]);
|
||||
collectionRelationship = result.collectionRelationships.shift();
|
||||
});
|
||||
|
||||
it("should convert multi-level folders to collections when importing into an organization", async () => {
|
||||
importer.organizationId = "someOrg";
|
||||
const result: ImportResult = await importer.parse(withMultipleFolders);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ciphers.length).toBe(5);
|
||||
|
||||
expect(result.collections.length).toBe(3);
|
||||
expect(result.collections[0].name).toEqual("Test Folder");
|
||||
expect(result.collections[1].name).toEqual("Test Folder/Level 2 Folder");
|
||||
expect(result.collections[2].name).toEqual("Test Folder/Level 2 Folder/Level 3 Folder");
|
||||
|
||||
expect(result.collectionRelationships.length).toBe(4);
|
||||
expect(result.collectionRelationships[0]).toEqual([1, 0]);
|
||||
expect(result.collectionRelationships[1]).toEqual([2, 1]);
|
||||
expect(result.collectionRelationships[2]).toEqual([3, 1]);
|
||||
expect(result.collectionRelationships[3]).toEqual([4, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { RoboFormCsvImporter } from "./roboform-csv-importer";
|
||||
import { data as dataNoFolder } from "./spec-data/roboform-csv/empty-folders";
|
||||
import { data as dataFolder } from "./spec-data/roboform-csv/with-folders";
|
||||
import { data as dataFolder, dataWithFolderHierarchy } from "./spec-data/roboform-csv/with-folders";
|
||||
|
||||
describe("Roboform CSV Importer", () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,4 +39,19 @@ describe("Roboform CSV Importer", () => {
|
||||
expect(result.ciphers[4].notes).toBe("This is a safe note");
|
||||
expect(result.ciphers[4].name).toBe("note - 2023-03-31");
|
||||
});
|
||||
|
||||
it("should parse CSV data with folder hierarchy", async () => {
|
||||
const importer = new RoboFormCsvImporter();
|
||||
const result = await importer.parse(dataWithFolderHierarchy);
|
||||
expect(result != null).toBe(true);
|
||||
|
||||
expect(result.folders.length).toBe(5);
|
||||
expect(result.ciphers.length).toBe(5);
|
||||
|
||||
expect(result.folders[0].name).toBe("folder1");
|
||||
expect(result.folders[1].name).toBe("folder2");
|
||||
expect(result.folders[2].name).toBe("folder2/folder3");
|
||||
expect(result.folders[3].name).toBe("folder1/folder2/folder3");
|
||||
expect(result.folders[4].name).toBe("folder1/folder2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,3 +2,9 @@ export const testData = `"Foo","Bar","john.doe@example.com","1234567890abcdef","
|
||||
"Foo","Bar 1","john.doe1@example.com","234567890abcdef1","https://an.example.com/","","","Account ID","12345","Org ID","54321"
|
||||
"Foo\\Baz","Bar 2","john.doe2@example.com","34567890abcdef12","https://another.example.com/","","","Account ID","23456","TFC:Keeper","otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30"
|
||||
`;
|
||||
|
||||
export const testDataMultiCollection = `
|
||||
"Foo\\Baz\\Bar","Bar 2","john.doe2@example.com","34567890abcdef12","https://another.example.com/","","","Account ID","23456","TFC:Keeper","otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30"
|
||||
"Foo\\Baz","Bar 2","john.doe2@example.com","34567890abcdef12","https://another.example.com/","","","Account ID","23456","TFC:Keeper","otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30"
|
||||
"Foo","Bar 2","john.doe2@example.com","34567890abcdef12","https://another.example.com/","","","Account ID","23456","TFC:Keeper","otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30"
|
||||
`;
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
"folderOrCollection1";"tag1, tag2, tag3";"Test Entry 1";"someUser";"somePassword";"https://www.example.com";"some note for example.com";"someTOTPSeed"
|
||||
"folderOrCollection2";"tag2";"Test Entry 2";"jdoe";"})9+Kg2fz_O#W1§H1-<ox>0Zio";"www.123.com";"Description123";"anotherTOTP"
|
||||
"folderOrCollection1";"someTag";"Test Entry 3";"username";"password";"www.internetsite.com";"Information";""`;
|
||||
|
||||
export const credentialsDataWithFolders = `"Organisationseinheit";"DataTags";"Beschreibung";"Benutzername";"Passwort";"Internetseite";"Informationen";"One-Time Passwort"
|
||||
"folder1\\folder2\\folder3";"tag1, tag2, tag3";"Test Entry 1";"someUser";"somePassword";"https://www.example.com";"some note for example.com";"someTOTPSeed"`;
|
||||
|
||||
@@ -11,3 +11,17 @@ test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;
|
||||
|
||||
[Cert folder\\Nested folder];
|
||||
test2;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;`;
|
||||
|
||||
export const withMultipleFolders = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by
|
||||
>>>
|
||||
Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;;;
|
||||
|
||||
[Test Folder]
|
||||
Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;;
|
||||
|
||||
[Test Folder\\Level 2 Folder]
|
||||
Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;;
|
||||
test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;
|
||||
|
||||
[Test Folder\\Level 2 Folder\\Level 3 Folder]
|
||||
test2;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;`;
|
||||
|
||||
@@ -4,3 +4,10 @@ Test,https://www.test.com/,https://www.test.com/,test@gmail.com,:testPassword,te
|
||||
LoginWebsite,https://login.Website.com/,https://login.Website.com/,test@outlook.com,123password,,folder2,"User ID$,,,txt,test@outlook.com","Password$,,,pwd,123password"
|
||||
Website,https://signin.website.com/,https://signin.website.com/,user@bitwarden.com,password123,Website ,folder3,"User ID$,,,txt,user@bitwarden.com","Password$,,,pwd,password123"
|
||||
note - 2023-03-31,,,,,This is a safe note,`;
|
||||
|
||||
export const dataWithFolderHierarchy = `Name,Url,MatchUrl,Login,Pwd,Note,Folder,RfFieldsV2
|
||||
Bitwarden,https://bitwarden.com,https://bitwarden.com,user@bitwarden.com,password,,folder1,"User ID$,,,txt,user@bitwarden.com","Password$,,,pwd,password"
|
||||
Test,https://www.test.com/,https://www.test.com/,test@gmail.com,:testPassword,test,folder1,"User ID$,,,txt,test@gmail.com","Password$,,,pwd,:testPassword"
|
||||
LoginWebsite,https://login.Website.com/,https://login.Website.com/,test@outlook.com,123password,,folder2,"User ID$,,,txt,test@outlook.com","Password$,,,pwd,123password"
|
||||
Website,https://signin.website.com/,https://signin.website.com/,user@bitwarden.com,password123,Website ,folder2\\folder3,"User ID$,,,txt,user@bitwarden.com","Password$,,,pwd,password123"
|
||||
Website,https://signin.website.com/,https://signin.website.com/,user@bitwarden.com,password123,Website ,folder1\\folder2\\folder3,"User ID$,,,txt,user@bitwarden.com","Password$,,,pwd,password123"`;
|
||||
|
||||
@@ -55,6 +55,10 @@ describe("Addy.io forwarder", () => {
|
||||
|
||||
const result = AddyIo.forwarder.createForwardingEmail.body(null, context);
|
||||
|
||||
expect(context.generatedBy).toHaveBeenCalledWith(null, {
|
||||
extractHostname: true,
|
||||
maxLength: 200,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
domain: "domain",
|
||||
description: "generated by",
|
||||
|
||||
@@ -39,7 +39,7 @@ const createForwardingEmail = Object.freeze({
|
||||
body(request: IntegrationRequest, context: ForwarderContext<AddyIoSettings>) {
|
||||
return {
|
||||
domain: context.emailDomain(),
|
||||
description: context.generatedBy(request),
|
||||
description: context.generatedBy(request, { extractHostname: true, maxLength: 200 }),
|
||||
};
|
||||
},
|
||||
hasJsonPayload(response: Response) {
|
||||
|
||||
@@ -56,6 +56,11 @@ describe("Firefox Relay forwarder", () => {
|
||||
|
||||
const result = FirefoxRelay.forwarder.createForwardingEmail.body(null, context);
|
||||
|
||||
expect(context.website).toHaveBeenCalledWith(null, { maxLength: 255 });
|
||||
expect(context.generatedBy).toHaveBeenCalledWith(null, {
|
||||
extractHostname: true,
|
||||
maxLength: 64,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
generated_for: "website",
|
||||
|
||||
@@ -33,8 +33,8 @@ const createForwardingEmail = Object.freeze({
|
||||
body(request: IntegrationRequest, context: ForwarderContext<FirefoxRelaySettings>) {
|
||||
return {
|
||||
enabled: true,
|
||||
generated_for: context.website(request),
|
||||
description: context.generatedBy(request),
|
||||
generated_for: context.website(request, { maxLength: 255 }),
|
||||
description: context.generatedBy(request, { extractHostname: true, maxLength: 64 }),
|
||||
};
|
||||
},
|
||||
hasJsonPayload(response: Response) {
|
||||
|
||||
Reference in New Issue
Block a user