1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 19:11:22 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Victoria League
2024-11-04 09:29:31 -05:00
committed by GitHub
282 changed files with 10021 additions and 3864 deletions

View File

@@ -267,6 +267,7 @@ export class LockComponent implements OnInit, OnDestroy {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
response.masterKey,
userId,
);
await this.setUserKeyAndContinue(userKey, userId, true);
}

View File

@@ -46,7 +46,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
await this.onSubmit(this.taxInformation);
await this.onSubmit?.(this.taxInformation);
this.taxInformationUpdated.emit();
};

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from "@angular/core";
import { fromEvent, map, merge, Observable, of, Subscription, switchMap } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeTypes, Theme } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { SYSTEM_THEME_OBSERVABLE } from "../../../services/injection-tokens";
@@ -15,7 +15,7 @@ export class AngularThemingService implements AbstractThemingService {
* @param window The window that should be watched for system theme changes.
* @returns An observable that will track the system theme.
*/
static createSystemThemeFromWindow(window: Window): Observable<ThemeType> {
static createSystemThemeFromWindow(window: Window): Observable<Theme> {
return merge(
// This observable should always emit at least once, so go and get the current system theme designation
of(AngularThemingService.getSystemThemeFromWindow(window)),
@@ -23,7 +23,7 @@ export class AngularThemingService implements AbstractThemingService {
fromEvent<MediaQueryListEvent>(
window.matchMedia("(prefers-color-scheme: dark)"),
"change",
).pipe(map((event) => (event.matches ? ThemeType.Dark : ThemeType.Light))),
).pipe(map((event) => (event.matches ? ThemeTypes.Dark : ThemeTypes.Light))),
);
}
@@ -32,15 +32,15 @@ export class AngularThemingService implements AbstractThemingService {
* @param window The window to query for the current theme.
* @returns The active system theme.
*/
static getSystemThemeFromWindow(window: Window): ThemeType {
static getSystemThemeFromWindow(window: Window): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeType.Dark
: ThemeType.Light;
? ThemeTypes.Dark
: ThemeTypes.Light;
}
readonly theme$ = this.themeStateService.selectedTheme$.pipe(
switchMap((configuredTheme) => {
if (configuredTheme === ThemeType.System) {
if (configuredTheme === ThemeTypes.System) {
return this.systemTheme$;
}
@@ -51,16 +51,16 @@ export class AngularThemingService implements AbstractThemingService {
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private systemTheme$: Observable<Theme>,
) {}
applyThemeChangesTo(document: Document): Subscription {
return this.theme$.subscribe((theme) => {
document.documentElement.classList.remove(
"theme_" + ThemeType.Light,
"theme_" + ThemeType.Dark,
"theme_" + ThemeType.Nord,
"theme_" + ThemeType.SolarizedDark,
"theme_" + ThemeTypes.Light,
"theme_" + ThemeTypes.Dark,
"theme_" + ThemeTypes.Nord,
"theme_" + ThemeTypes.SolarizedDark,
);
document.documentElement.classList.add("theme_" + theme);
});

View File

@@ -1,6 +1,6 @@
import { Observable, Subscription } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Theme } from "@bitwarden/common/platform/enums";
/**
* A service for managing and observing the current application theme.
@@ -9,9 +9,9 @@ import { ThemeType } from "@bitwarden/common/platform/enums";
export abstract class AbstractThemingService {
/**
* The effective theme based on the user configured choice and the current system theme if
* the configured choice is {@link ThemeType.System}.
* the configured choice is {@link ThemeTypes.System}.
*/
abstract theme$: Observable<ThemeType>;
abstract theme$: Observable<Theme>;
/**
* Listens for effective theme changes and applies changes to the provided document.
* @param document The document that should have theme classes applied to it.

View File

@@ -8,7 +8,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { VaultTimeout } from "@bitwarden/common/types/vault-timeout.type";
@@ -47,7 +47,7 @@ export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
export const LOG_MAC_FAILURES = new SafeInjectionToken<boolean>("LOG_MAC_FAILURES");
export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<ThemeType>>(
export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<Theme>>(
"SYSTEM_THEME_OBSERVABLE",
);
export const DEFAULT_VAULT_TIMEOUT = new SafeInjectionToken<VaultTimeout>("DEFAULT_VAULT_TIMEOUT");

View File

@@ -921,7 +921,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalMasterPasswordServiceAbstraction,
useClass: MasterPasswordService,
deps: [StateProvider, StateServiceAbstraction, KeyGenerationServiceAbstraction, EncryptService],
deps: [
StateProvider,
StateServiceAbstraction,
KeyGenerationServiceAbstraction,
EncryptService,
LogService,
],
}),
safeProvider({
provide: MasterPasswordServiceAbstraction,

View File

@@ -483,6 +483,7 @@ export class LockV2Component implements OnInit, OnDestroy {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterPasswordVerificationResponse.masterKey,
this.activeAccount.id,
);
await this.setUserKeyAndContinue(userKey, true);
}

View File

@@ -114,7 +114,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
private async trySetUserKeyWithMasterKey(userId: UserId): Promise<void> {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -183,7 +183,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -496,7 +496,7 @@ describe("SsoLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
@@ -552,7 +552,7 @@ describe("SsoLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);

View File

@@ -338,7 +338,7 @@ export class SsoLoginStrategy extends LoginStrategy {
return;
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
await this.keyService.setUserKey(userKey, userId);
}

View File

@@ -213,7 +213,7 @@ describe("UserApiLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);

View File

@@ -69,7 +69,10 @@ export class UserApiLoginStrategy extends LoginStrategy {
if (response.apiUseKeyConnector) {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -200,7 +200,7 @@ describe("AuthRequestService", () => {
);
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockDecryptedMasterKey,
undefined,
mockUserId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey, mockUserId);

View File

@@ -150,7 +150,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
);
// Decrypt and set user key in state
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
await this.masterPasswordService.setMasterKey(masterKey, userId);

View File

@@ -418,6 +418,7 @@ export class PinService implements PinServiceAbstraction {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
encUserKey ? new EncString(encUserKey) : undefined,
);

View File

@@ -33,16 +33,16 @@ export abstract class MasterPasswordServiceAbstraction {
/**
* Decrypts the user key with the provided master key
* @param masterKey The user's master key
* * @param userId The desired user
* @param userKey The user's encrypted symmetric key
* @param userId The desired user
* @throws If either the MasterKey or UserKey are not resolved, or if the UserKey encryption type
* is neither AesCbc256_B64 nor AesCbc256_HmacSha256_B64
* @returns The user key
*/
abstract decryptUserKeyWithMasterKey: (
masterKey: MasterKey,
userId: string,
userKey?: EncString,
userId?: string,
) => Promise<UserKey>;
}

View File

@@ -64,9 +64,9 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
decryptUserKeyWithMasterKey(
masterKey: MasterKey,
userId: string,
userKey?: EncString,
userId?: string,
): Promise<UserKey> {
return this.mock.decryptUserKeyWithMasterKey(masterKey, userKey, userId);
return this.mock.decryptUserKeyWithMasterKey(masterKey, userId, userKey);
}
}

View File

@@ -1,5 +1,7 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { StateService } from "../../../platform/abstractions/state.service";
@@ -55,6 +57,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
private stateService: StateService,
private keyGenerationService: KeyGenerationService,
private encryptService: EncryptService,
private logService: LogService,
) {}
masterKey$(userId: UserId): Observable<MasterKey> {
@@ -149,10 +152,9 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
async decryptUserKeyWithMasterKey(
masterKey: MasterKey,
userId: UserId,
userKey?: EncString,
userId?: UserId,
): Promise<UserKey> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
userKey ??= await this.getMasterKeyEncryptedUserKey(userId);
masterKey ??= await firstValueFrom(this.masterKey$(userId));
@@ -185,6 +187,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
}
if (decUserKey == null) {
this.logService.warning("Failed to decrypt user key with master key.");
return null;
}

View File

@@ -2,11 +2,13 @@ import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationBillingMetadataResponse extends BaseResponse {
isEligibleForSelfHost: boolean;
isManaged: boolean;
isOnSecretsManagerStandalone: boolean;
constructor(response: any) {
super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
}
}

View File

@@ -1,3 +1,6 @@
/**
* @deprecated prefer the `ThemeTypes` constants and `Theme` type over unsafe enum types
**/
export enum ThemeType {
System = "system",
Light = "light",
@@ -5,3 +8,13 @@ export enum ThemeType {
Nord = "nord",
SolarizedDark = "solarizedDark",
}
export const ThemeTypes = {
System: "system",
Light: "light",
Dark: "dark",
Nord: "nord",
SolarizedDark: "solarizedDark",
} as const;
export type Theme = (typeof ThemeTypes)[keyof typeof ThemeTypes];

View File

@@ -0,0 +1 @@
export * from "./rxjs-operators";

View File

@@ -0,0 +1,58 @@
import { firstValueFrom, of } from "rxjs";
import { getById, getByIds } from "./rxjs-operators";
describe("custom rxjs operators", () => {
describe("getById", () => {
it("returns an object with a matching id", async () => {
const obs = of([
{
id: 1,
data: "one",
},
{
id: 2,
data: "two",
},
{
id: 3,
data: "three",
},
]).pipe(getById(2));
const result = await firstValueFrom(obs);
expect(result).toEqual({ id: 2, data: "two" });
});
});
describe("getByIds", () => {
it("returns an array of objects with matching ids", async () => {
const obs = of([
{
id: 1,
data: "one",
},
{
id: 2,
data: "two",
},
{
id: 3,
data: "three",
},
{
id: 4,
data: "four",
},
]).pipe(getByIds([2, 3]));
const result = await firstValueFrom(obs);
expect(result).toEqual([
{ id: 2, data: "two" },
{ id: 3, data: "three" },
]);
});
});
});

View File

@@ -0,0 +1,21 @@
import { map } from "rxjs";
/**
* An rxjs operator that extracts an object by ID from an array of objects.
* @param id The ID of the object to return.
* @returns The first object with a matching ID, or undefined if no matching object is present.
*/
export const getById = <TId, T extends { id: TId }>(id: TId) =>
map<T[], T | undefined>((objects) => objects.find((o) => o.id === id));
/**
* An rxjs operator that extracts a subset of objects by their IDs from an array of objects.
* @param id The IDs of the objects to return.
* @returns An array containing objects with matching IDs, or an empty array if there are no matching objects.
*/
export const getByIds = <TId, T extends { id: TId }>(ids: TId[]) => {
const idSet = new Set(ids);
return map<T[], T[]>((objects) => {
return objects.filter((o) => idSet.has(o.id));
});
};

View File

@@ -82,6 +82,9 @@ type BaseCipherFormConfig = {
/** Hides the fields that are only applicable to individuals, useful in the Admin Console where folders aren't applicable */
hideIndividualVaultFields?: true;
/** True when the config is built within the context of the Admin Console */
isAdminConsole?: true;
};
/**

View File

@@ -13,6 +13,7 @@ import { CollectionView } from "@bitwarden/admin-console/common";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ClientType } from "@bitwarden/common/enums";
@@ -183,6 +184,12 @@ export default {
getClientType: () => ClientType.Browser,
},
},
{
provide: AccountService,
useValue: {
activeAccount$: new BehaviorSubject({ email: "test@example.com" }),
},
},
],
}),
componentWrapperDecorator(

View File

@@ -27,9 +27,9 @@
<bit-label>{{ "owner" | i18n }}</bit-label>
<bit-select formControlName="organizationId">
<bit-option
*ngIf="allowPersonalOwnership"
*ngIf="showPersonalOwnerOption"
[value]="null"
[label]="'selfOwnershipLabel' | i18n"
[label]="userEmail$ | async"
></bit-option>
<bit-option
*ngFor="let org of config.organizations"

View File

@@ -1,13 +1,17 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SelectComponent } from "@bitwarden/components";
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
import { CipherFormContainer } from "../../cipher-form-container";
@@ -20,6 +24,8 @@ describe("ItemDetailsSectionComponent", () => {
let cipherFormProvider: MockProxy<CipherFormContainer>;
let i18nService: MockProxy<I18nService>;
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
beforeEach(async () => {
cipherFormProvider = mock<CipherFormContainer>();
i18nService = mock<I18nService>();
@@ -29,6 +35,7 @@ describe("ItemDetailsSectionComponent", () => {
providers: [
{ provide: CipherFormContainer, useValue: cipherFormProvider },
{ provide: I18nService, useValue: i18nService },
{ provide: AccountService, useValue: { activeAccount$ } },
],
}).compileComponents();
@@ -207,6 +214,35 @@ describe("ItemDetailsSectionComponent", () => {
});
});
describe("showPersonalOwnerOption", () => {
it("should show personal ownership when the configuration allows", () => {
component.config.mode = "edit";
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "134-433-22" } as Organization];
fixture.detectChanges();
const select = fixture.debugElement.query(By.directive(SelectComponent));
const { value, label } = select.componentInstance.items[0];
expect(value).toBeNull();
expect(label).toBe("test@example.com");
});
it("should show personal ownership when the control is disabled", async () => {
component.config.mode = "edit";
component.config.allowPersonalOwnership = false;
component.config.organizations = [{ id: "134-433-22" } as Organization];
await component.ngOnInit();
fixture.detectChanges();
const select = fixture.debugElement.query(By.directive(SelectComponent));
const { value, label } = select.componentInstance.items[0];
expect(value).toBeNull();
expect(label).toBe("test@example.com");
});
});
describe("showOwnership", () => {
it("should return true if ownership change is allowed or in edit mode with at least one organization", () => {
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true);

View File

@@ -7,6 +7,7 @@ import { concatMap, map } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -68,6 +69,9 @@ export class ItemDetailsSectionComponent implements OnInit {
protected showCollectionsControl: boolean;
/** The email address associated with the active account */
protected userEmail$ = this.accountService.activeAccount$.pipe(map((account) => account.email));
@Input({ required: true })
config: CipherFormConfig;
@@ -96,11 +100,23 @@ export class ItemDetailsSectionComponent implements OnInit {
return this.config.initialValues;
}
/**
* Show the personal ownership option in the Owner dropdown when:
* - Personal ownership is allowed
* - The `organizationId` control is disabled. This avoids the scenario
* where a the dropdown is empty because the user personally owns the cipher
* but cannot edit the ownership.
*/
get showPersonalOwnerOption() {
return this.allowPersonalOwnership || !this.itemDetailsForm.controls.organizationId.enabled;
}
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private destroyRef: DestroyRef,
private accountService: AccountService,
) {
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
this.itemDetailsForm.valueChanges
@@ -147,9 +163,13 @@ export class ItemDetailsSectionComponent implements OnInit {
}
get showOwnership() {
return (
this.allowOwnershipChange || (this.organizations.length > 0 && this.config.mode === "edit")
);
// Show ownership field when editing with available orgs
const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit";
// When in admin console, ownership should not be shown unless cloning
const isAdminConsoleEdit = this.config.isAdminConsole && this.config.mode !== "clone";
return this.allowOwnershipChange || (isEditingWithOrgs && !isAdminConsoleEdit);
}
get defaultOwner() {