1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 18:23:31 +00:00

Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-10426/admin-console-add-edit

This commit is contained in:
Nick Krantz
2024-10-02 21:27:37 -05:00
274 changed files with 6538 additions and 1624 deletions

View File

@@ -0,0 +1,16 @@
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService {
getAll: (organizationId: string) => Promise<CollectionAdminView[]>;
get: (organizationId: string, collectionId: string) => Promise<CollectionAdminView | undefined>;
save: (collection: CollectionAdminView) => Promise<CollectionDetailsResponse>;
delete: (organizationId: string, collectionId: string) => Promise<void>;
bulkAssignAccess: (
organizationId: string,
collectionIds: string[],
users: CollectionAccessSelectionView[],
groups: CollectionAccessSelectionView[],
) => Promise<void>;
}

View File

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

View File

@@ -0,0 +1,3 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";

View File

@@ -0,0 +1,7 @@
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
export class BulkCollectionAccessRequest {
collectionIds: string[];
users: SelectionReadOnlyRequest[];
groups: SelectionReadOnlyRequest[];
}

View File

@@ -0,0 +1,28 @@
import { View } from "@bitwarden/common/models/view/view";
interface SelectionResponseLike {
id: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}
export class CollectionAccessSelectionView extends View {
readonly id: string;
readonly readOnly: boolean;
readonly hidePasswords: boolean;
readonly manage: boolean;
constructor(response?: SelectionResponseLike) {
super();
if (!response) {
return;
}
this.id = response.id;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
this.manage = response.manage;
}
}

View File

@@ -0,0 +1,97 @@
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { CollectionAccessSelectionView } from "../models";
export const Unassigned = "unassigned";
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
/**
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
*/
unmanaged: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response?: CollectionAccessDetailsResponse) {
super(response);
if (!response) {
return;
}
this.groups = response.groups
? response.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
this.users = response.users
? response.users.map((g) => new CollectionAccessSelectionView(g))
: [];
this.assigned = response.assigned;
}
/**
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/
override canEdit(org: Organization): boolean {
return (
org?.canEditAnyCollection ||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
super.canEdit(org)
);
}
/**
* Returns true if the user can delete a collection from the Admin Console.
*/
override canDelete(org: Organization): boolean {
return org?.canDeleteAnyCollection || super.canDelete(org);
}
/**
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization): boolean {
return (
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
);
}
/**
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization): boolean {
return (
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
this.canEdit(org)
);
}
/**
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection) {
return false;
}
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
}
/**
* True if this collection represents the pseudo "Unassigned" collection
* This is different from the "unmanaged" flag, which indicates that no users or groups have access to the collection
*/
get isUnassignedCollection() {
return this.id === Unassigned;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./bulk-collection-access.request";
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";

View File

@@ -0,0 +1,169 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { CollectionRequest } from "@bitwarden/common/vault/models/request/collection.request";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionAdminService } from "../abstractions";
import {
BulkCollectionAccessRequest,
CollectionAccessSelectionView,
CollectionAdminView,
} from "../models";
export class DefaultCollectionAdminService implements CollectionAdminService {
constructor(
private apiService: ApiService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private collectionService: CollectionService,
) {}
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
const collectionResponse =
await this.apiService.getManyCollectionsWithAccessDetails(organizationId);
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
return [];
}
return await this.decryptMany(organizationId, collectionResponse.data);
}
async get(
organizationId: string,
collectionId: string,
): Promise<CollectionAdminView | undefined> {
const collectionResponse = await this.apiService.getCollectionAccessDetails(
organizationId,
collectionId,
);
if (collectionResponse == null) {
return undefined;
}
const [view] = await this.decryptMany(organizationId, [collectionResponse]);
return view;
}
async save(collection: CollectionAdminView): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection);
let response: CollectionDetailsResponse;
if (collection.id == null) {
response = await this.apiService.postCollection(collection.organizationId, request);
collection.id = response.id;
} else {
response = await this.apiService.putCollection(
collection.organizationId,
collection.id,
request,
);
}
if (response.assigned) {
await this.collectionService.upsert(new CollectionData(response));
} else {
await this.collectionService.delete(collection.id);
}
return response;
}
async delete(organizationId: string, collectionId: string): Promise<void> {
await this.apiService.deleteCollection(organizationId, collectionId);
}
async bulkAssignAccess(
organizationId: string,
collectionIds: string[],
users: CollectionAccessSelectionView[],
groups: CollectionAccessSelectionView[],
): Promise<void> {
const request = new BulkCollectionAccessRequest();
request.collectionIds = collectionIds;
request.users = users.map(
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
);
request.groups = groups.map(
(g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage),
);
await this.apiService.send(
"POST",
`/organizations/${organizationId}/collections/bulk-access`,
request,
true,
false,
);
}
private async decryptMany(
organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[],
): Promise<CollectionAdminView[]> {
const orgKey = await this.cryptoService.getOrgKey(organizationId);
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.encryptService.decryptToUtf8(new EncString(c.name), orgKey);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
if (isCollectionAccessDetailsResponse(c)) {
view.groups = c.groups;
view.users = c.users;
view.assigned = c.assigned;
view.readOnly = c.readOnly;
view.hidePasswords = c.hidePasswords;
view.manage = c.manage;
view.unmanaged = c.unmanaged;
}
return view;
});
return await Promise.all(promises);
}
private async encrypt(model: CollectionAdminView): Promise<CollectionRequest> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.cryptoService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const collection = new CollectionRequest();
collection.externalId = model.externalId;
collection.name = (await this.encryptService.encrypt(model.name, key)).encryptedString;
collection.groups = model.groups.map(
(group) =>
new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords, group.manage),
);
collection.users = model.users.map(
(user) =>
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage),
);
return collection;
}
}
function isCollectionAccessDetailsResponse(
response: CollectionResponse | CollectionAccessDetailsResponse,
): response is CollectionAccessDetailsResponse {
const anyResponse = response as any;
return anyResponse?.groups instanceof Array && anyResponse?.users instanceof Array;
}

View File

@@ -0,0 +1 @@
export * from "./default-collection-admin.service";

View File

@@ -1 +1,2 @@
export * from "./organization-user";
export * from "./collections";

View File

@@ -16,6 +16,7 @@ import {
OrganizationUserDetailsResponse,
OrganizationUserResetPasswordDetailsResponse,
OrganizationUserUserDetailsResponse,
OrganizationUserUserMiniResponse,
} from "../models/responses";
/**
@@ -44,7 +45,9 @@ export abstract class OrganizationUserApiService {
abstract getOrganizationUserGroups(organizationId: string, id: string): Promise<string[]>;
/**
* Retrieve a list of all users that belong to the specified organization
* Retrieve full details of all users that belong to the specified organization.
* This is only accessible to privileged users, if you need a simple listing of basic details, use
* {@link getAllMiniUserDetails}.
* @param organizationId - Identifier for the organization
* @param options - Options for the request
*/
@@ -56,6 +59,16 @@ export abstract class OrganizationUserApiService {
},
): Promise<ListResponse<OrganizationUserUserDetailsResponse>>;
/**
* Retrieve a list of all users that belong to the specified organization, with basic information only.
* This is suitable for lists of names/emails etc. throughout the app and can be accessed by most users.
* @param organizationId - Identifier for the organization
* @param options - Options for the request
*/
abstract getAllMiniUserDetails(
organizationId: string,
): Promise<ListResponse<OrganizationUserUserMiniResponse>>;
/**
* Retrieve reset password details for the specified organization user
* @param organizationId - Identifier for the user's organization

View File

@@ -1,3 +1,4 @@
export * from "./organization-user.response";
export * from "./organization-user-bulk.response";
export * from "./organization-user-bulk-public-key.response";
export * from "./organization-user-mini.response";

View File

@@ -0,0 +1,24 @@
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class OrganizationUserUserMiniResponse extends BaseResponse {
id: string;
userId: string;
email: string;
name: string;
type: OrganizationUserType;
status: OrganizationUserStatusType;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.userId = this.getResponseProperty("UserId");
this.email = this.getResponseProperty("Email");
this.name = this.getResponseProperty("Name");
this.type = this.getResponseProperty("Type");
this.status = this.getResponseProperty("Status");
}
}

View File

@@ -1,5 +1,9 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { OrganizationUserApiService } from "../abstractions";
import {
@@ -19,10 +23,14 @@ import {
OrganizationUserDetailsResponse,
OrganizationUserResetPasswordDetailsResponse,
OrganizationUserUserDetailsResponse,
OrganizationUserUserMiniResponse,
} from "../models/responses";
export class DefaultOrganizationUserApiService implements OrganizationUserApiService {
constructor(private apiService: ApiService) {}
constructor(
private apiService: ApiService,
private configService: ConfigService,
) {}
async getOrganizationUser(
organizationId: string,
@@ -84,6 +92,27 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
return new ListResponse(r, OrganizationUserUserDetailsResponse);
}
async getAllMiniUserDetails(
organizationId: string,
): Promise<ListResponse<OrganizationUserUserMiniResponse>> {
const apiEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.Pm3478RefactorOrganizationUserApi),
);
if (!apiEnabled) {
// Keep using the old api until this feature flag is enabled
return this.getAllUsers(organizationId);
}
const r = await this.apiService.send(
"GET",
`/organizations/${organizationId}/users/mini-details`,
null,
true,
true,
);
return new ListResponse(r, OrganizationUserUserMiniResponse);
}
async getOrganizationUserResetPasswordDetails(
organizationId: string,
id: string,

View File

@@ -29,14 +29,13 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
@Directive()
export class LockComponent implements OnInit, OnDestroy {

View File

@@ -1,6 +1,6 @@
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute, NavigationSkipped, Router } from "@angular/router";
import { Subject, firstValueFrom, of } from "rxjs";
import { switchMap, take, takeUntil } from "rxjs/operators";
@@ -121,6 +121,14 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
)
.subscribe();
// If the user navigates to /login from /login, reset the validatedEmail flag
// This should bring the user back to the login screen with the email field
this.router.events.pipe(takeUntil(this.destroy$)).subscribe((event) => {
if (event instanceof NavigationSkipped && event.url === "/login") {
this.validatedEmail = false;
}
});
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.route) {

View File

@@ -215,7 +215,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// RSA Encrypt user key with organization public key
const userKey = await this.cryptoService.getUserKey();
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
const encryptedUserKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterPasswordHash;

View File

@@ -38,6 +38,8 @@ export const authGuard: CanActivateFn = async (
if (routerState != null) {
messagingService.send("lockedUrl", { url: routerState.url });
}
// TODO PM-9674: when extension refresh is finished, remove promptBiometric
// as it has been integrated into the component as a default feature.
return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
}

View File

@@ -151,10 +151,6 @@ import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwar
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
import {
BiometricStateService,
DefaultBiometricStateService,
} from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
@@ -263,6 +259,7 @@ import {
ImportService,
ImportServiceAbstraction,
} from "@bitwarden/importer/core";
import { BiometricStateService, DefaultBiometricStateService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
VaultExportService,
@@ -955,7 +952,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationUserApiService,
useClass: DefaultOrganizationUserApiService,
deps: [ApiServiceAbstraction],
deps: [ApiServiceAbstraction, ConfigService],
}),
safeProvider({
provide: PasswordResetEnrollmentServiceAbstraction,
@@ -964,6 +961,7 @@ const safeProviders: SafeProvider[] = [
OrganizationApiServiceAbstraction,
AccountServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
OrganizationUserApiService,
I18nServiceAbstraction,
],
@@ -1093,6 +1091,7 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
ApiServiceAbstraction,
StateProvider,
],
@@ -1288,6 +1287,7 @@ const safeProviders: SafeProvider[] = [
deps: [
ApiServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
I18nServiceAbstraction,
KdfConfigServiceAbstraction,
InternalMasterPasswordServiceAbstraction,

View File

@@ -308,9 +308,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.folders$ = this.folderService.folderViews$;
if (this.editMode && this.previousCipherId !== this.cipherId) {
// 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.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientViewed, [this.cipher]);
}
this.previousCipherId = this.cipherId;
this.reprompt = this.cipher.reprompt !== CipherRepromptType.None;
@@ -551,12 +549,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (this.editMode && this.showPassword) {
document.getElementById("loginPassword")?.focus();
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledPasswordVisible,
this.cipherId,
);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledPasswordVisible, [
this.cipher,
]);
}
}
@@ -566,23 +561,18 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (this.editMode && this.showTotpSeed) {
document.getElementById("loginTotp")?.focus();
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledTOTPSeedVisible,
this.cipherId,
);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledTOTPSeedVisible, [
this.cipher,
]);
}
}
async toggleCardNumber() {
this.showCardNumber = !this.showCardNumber;
if (this.showCardNumber) {
// 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.eventCollectionService.collect(
void this.eventCollectionService.collectMany(
EventType.Cipher_ClientToggledCardNumberVisible,
this.cipherId,
[this.cipher],
);
}
}
@@ -591,12 +581,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.showCardCode = !this.showCardCode;
document.getElementById("cardCode").focus();
if (this.editMode && this.showCardCode) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledCardCodeVisible,
this.cipherId,
);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledCardCodeVisible, [
this.cipher,
]);
}
}
@@ -742,17 +729,17 @@ export class AddEditComponent implements OnInit, OnDestroy {
);
if (typeI18nKey === "password") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, this.cipherId);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedPassword, [
this.cipher,
]);
} else if (typeI18nKey === "securityCode") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, this.cipherId);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedCardCode, [
this.cipher,
]);
} else if (aType === "H_Field") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId);
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedHiddenField, [
this.cipher,
]);
}
return true;

View File

@@ -9,7 +9,9 @@
'tw-min-h-[calc(100vh-54px)]': clientType === 'desktop',
}"
>
<bit-icon *ngIf="!hideLogo" [icon]="logo" class="tw-w-[128px] [&>*]:tw-align-top"></bit-icon>
<a *ngIf="!hideLogo" [routerLink]="['/']" class="tw-w-[128px] [&>*]:tw-align-top">
<bit-icon [icon]="logo"></bit-icon>
</a>
<div class="tw-text-center">
<div class="tw-mx-auto tw-max-w-28 sm:tw-max-w-32">

View File

@@ -1,5 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
@@ -15,7 +16,7 @@ import { BitwardenLogo, BitwardenShield } from "../icons";
standalone: true,
selector: "auth-anon-layout",
templateUrl: "./anon-layout.component.html",
imports: [IconModule, CommonModule, TypographyModule, SharedModule],
imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule],
})
export class AnonLayoutComponent implements OnInit, OnChanges {
@Input() title: string;

View File

@@ -1,5 +1,6 @@
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -22,7 +23,7 @@ export default {
component: AnonLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule],
imports: [ButtonModule, RouterModule],
providers: [
{
provide: PlatformUtilsService,
@@ -46,6 +47,10 @@ export default {
}).asObservable(),
},
},
{
provide: ActivatedRoute,
useValue: { queryParams: of({}) },
},
],
}),
],
@@ -66,7 +71,7 @@ export const WithPrimaryContent: Story = {
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
@@ -83,7 +88,7 @@ export const WithSecondaryContent: Story = {
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
// Notice that slot="secondary" is requred to project any secondary content.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
@@ -104,7 +109,7 @@ export const WithLongContent: Story = {
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname">
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
@@ -126,7 +131,7 @@ export const WithThinPrimaryContent: Story = {
template:
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname">
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
<div class="text-center">Lorem ipsum</div>
<div slot="secondary" class="text-center">
@@ -160,7 +165,7 @@ export const HideLogo: Story = {
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true">
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
@@ -176,7 +181,7 @@ export const HideFooter: Story = {
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true">
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true" [hideLogo]="hideLogo" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>

View File

@@ -43,5 +43,9 @@ export * from "./registration/registration-env-selector/registration-env-selecto
export * from "./registration/registration-finish/registration-finish.service";
export * from "./registration/registration-finish/default-registration-finish.service";
// lock
export * from "./lock/lock.component";
export * from "./lock/lock-component.service";
// vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component";

View File

@@ -0,0 +1,48 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
export enum BiometricsDisableReason {
NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem",
EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable",
SystemBiometricsUnavailable = "SystemBiometricsUnavailable",
}
// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics"
export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption];
export const UnlockOption = Object.freeze({
MasterPassword: "masterPassword",
Pin: "pin",
Biometrics: "biometrics",
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
export type UnlockOptions = {
masterPassword: {
enabled: boolean;
};
pin: {
enabled: boolean;
};
biometrics: {
enabled: boolean;
disableReason: BiometricsDisableReason | null;
};
};
/**
* The LockComponentService is a service which allows the single libs/auth LockComponent to delegate all
* client specific functionality to client specific services implementations of LockComponentService.
*/
export abstract class LockComponentService {
// Extension
abstract getBiometricsError(error: any): string | null;
abstract getPreviousUrl(): string | null;
// Desktop only
abstract isWindowVisible(): Promise<boolean>;
abstract getBiometricsUnlockBtnText(): string;
// Multi client
abstract getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions>;
}

View File

@@ -0,0 +1,191 @@
<ng-template #loading>
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
</ng-template>
<ng-container *ngIf="unlockOptions; else loading">
<!-- Biometrics Unlock -->
<ng-container
*ngIf="unlockOptions.biometrics.enabled && activeUnlockOption === UnlockOption.Biometrics"
>
<button
type="button"
bitButton
buttonType="primary"
class="tw-mb-3"
[disabled]="unlockingViaBiometrics"
[loading]="unlockingViaBiometrics"
block
(click)="unlockViaBiometrics()"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
<div class="tw-flex tw-flex-col tw-space-y-3">
<p class="tw-text-center tw-mb-0">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</ng-container>
<!-- PIN Unlock -->
<ng-container *ngIf="unlockOptions.pin.enabled && activeUnlockOption === UnlockOption.Pin">
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "pin" | i18n }}</bit-label>
<input
type="password"
formControlName="pin"
bitInput
appAutofocus
name="pin"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.biometrics.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
<!-- MP Unlock -->
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.biometrics.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,638 @@
import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
} from "@bitwarden/common/auth/types/verification";
import { ClientType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
ButtonModule,
DialogService,
FormFieldModule,
IconButtonModule,
ToastService,
} from "@bitwarden/components";
import { BiometricStateService } from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../common/abstractions";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import {
UnlockOption,
LockComponentService,
UnlockOptions,
UnlockOptionValue,
} from "./lock-component.service";
const BroadcasterSubscriptionId = "LockComponent";
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
[ClientType.Web]: "vault",
[ClientType.Desktop]: "vault",
[ClientType.Browser]: "/tabs/current",
};
@Component({
selector: "bit-lock",
templateUrl: "lock.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
ReactiveFormsModule,
ButtonModule,
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
],
})
export class LockV2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
activeAccount: { id: UserId | undefined } & AccountInfo;
clientType: ClientType;
ClientType = ClientType;
unlockOptions: UnlockOptions = null;
UnlockOption = UnlockOption;
private _activeUnlockOptionBSubject: BehaviorSubject<UnlockOptionValue> =
new BehaviorSubject<UnlockOptionValue>(null);
activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable();
set activeUnlockOption(value: UnlockOptionValue) {
this._activeUnlockOptionBSubject.next(value);
}
get activeUnlockOption(): UnlockOptionValue {
return this._activeUnlockOptionBSubject.value;
}
private invalidPinAttempts = 0;
biometricUnlockBtnText: string;
// masterPassword = "";
showPassword = false;
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
forcePasswordResetRoute = "update-temp-password";
formGroup: FormGroup;
// Desktop properties:
private deferFocus: boolean = null;
private biometricAsked = false;
// Browser extension properties:
private isInitialLockScreen = (window as any).previousPopupUrl == null;
defaultUnlockOptionSetForUser = false;
unlockingViaBiometrics = false;
constructor(
private accountService: AccountService,
private pinService: PinServiceAbstraction,
private userVerificationService: UserVerificationService,
private cryptoService: CryptoService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private dialogService: DialogService,
private messagingService: MessagingService,
private biometricStateService: BiometricStateService,
private ngZone: NgZone,
private i18nService: I18nService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private logService: LogService,
private deviceTrustService: DeviceTrustServiceAbstraction,
private syncService: SyncService,
private policyService: InternalPolicyService,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private formBuilder: FormBuilder,
private toastService: ToastService,
private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
// desktop deps
private broadcasterService: BroadcasterService,
) {}
async ngOnInit() {
this.listenForActiveUnlockOptionChanges();
// Listen for active account changes
this.listenForActiveAccountChanges();
// Identify client
this.clientType = this.platformUtilsService.getClientType();
if (this.clientType === "desktop") {
await this.desktopOnInit();
}
}
// Base component methods
private listenForActiveUnlockOptionChanges() {
this.activeUnlockOption$
.pipe(takeUntil(this.destroy$))
.subscribe((activeUnlockOption: UnlockOptionValue) => {
if (activeUnlockOption === UnlockOption.Pin) {
this.buildPinForm();
} else if (activeUnlockOption === UnlockOption.MasterPassword) {
this.buildMasterPasswordForm();
}
});
}
private buildMasterPasswordForm() {
this.formGroup = this.formBuilder.group(
{
masterPassword: ["", [Validators.required]],
},
{ updateOn: "submit" },
);
}
private buildPinForm() {
this.formGroup = this.formBuilder.group(
{
pin: ["", [Validators.required]],
},
{ updateOn: "submit" },
);
}
private listenForActiveAccountChanges() {
this.accountService.activeAccount$
.pipe(
switchMap((account) => {
return this.handleActiveAccountChange(account);
}),
takeUntil(this.destroy$),
)
.subscribe();
}
private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) {
this.activeAccount = activeAccount;
this.resetDataOnActiveAccountChange();
this.setEmailAsPageSubtitle(activeAccount.email);
this.unlockOptions = await firstValueFrom(
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
);
this.setDefaultActiveUnlockOption(this.unlockOptions);
if (this.unlockOptions.biometrics.enabled) {
await this.handleBiometricsUnlockEnabled();
}
}
private resetDataOnActiveAccountChange() {
this.defaultUnlockOptionSetForUser = false;
this.unlockOptions = null;
this.activeUnlockOption = null;
this.formGroup = null; // new form group will be created based on new active unlock option
// Desktop properties:
this.biometricAsked = false;
}
private setEmailAsPageSubtitle(email: string) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
subtitle: email,
translate: false,
},
});
}
private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions) {
// Priorities should be Biometrics > Pin > Master Password for speed
if (unlockOptions.biometrics.enabled) {
this.activeUnlockOption = UnlockOption.Biometrics;
} else if (unlockOptions.pin.enabled) {
this.activeUnlockOption = UnlockOption.Pin;
} else if (unlockOptions.masterPassword.enabled) {
this.activeUnlockOption = UnlockOption.MasterPassword;
}
}
private async handleBiometricsUnlockEnabled() {
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
const autoPromptBiometrics = await firstValueFrom(
this.biometricStateService.promptAutomatically$,
);
// TODO: PM-12546 - we need to make our biometric autoprompt experience consistent between the
// desktop and extension.
if (this.clientType === "desktop") {
if (autoPromptBiometrics) {
await this.desktopAutoPromptBiometrics();
}
}
if (this.clientType === "browser") {
if (
this.unlockOptions.biometrics.enabled &&
autoPromptBiometrics &&
this.isInitialLockScreen // only autoprompt biometrics on initial lock screen
) {
await this.unlockViaBiometrics();
}
}
}
// Note: this submit method is only used for unlock methods that require a form and user input.
// For biometrics unlock, the method is called directly.
submit = async (): Promise<void> => {
if (this.activeUnlockOption === UnlockOption.Pin) {
return await this.unlockViaPin();
}
await this.unlockViaMasterPassword();
};
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout", { userId: this.activeAccount.id });
}
}
async unlockViaBiometrics(): Promise<void> {
this.unlockingViaBiometrics = true;
if (!this.unlockOptions.biometrics.enabled) {
this.unlockingViaBiometrics = false;
return;
}
try {
await this.biometricStateService.setUserPromptCancelled();
const userKey = await this.cryptoService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
this.activeAccount.id,
);
// If user cancels biometric prompt, userKey is undefined.
if (userKey) {
await this.setUserKeyAndContinue(userKey, false);
}
this.unlockingViaBiometrics = false;
} catch (e) {
// Cancelling is a valid action.
if (e?.message === "canceled") {
this.unlockingViaBiometrics = false;
return;
}
let biometricTranslatedErrorDesc;
if (this.clientType === "browser") {
const biometricErrorDescTranslationKey = this.lockComponentService.getBiometricsError(e);
if (biometricErrorDescTranslationKey) {
biometricTranslatedErrorDesc = this.i18nService.t(biometricErrorDescTranslationKey);
}
}
// if no translation key found, show generic error message
if (!biometricTranslatedErrorDesc) {
biometricTranslatedErrorDesc = this.i18nService.t("unexpectedError");
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "error" },
content: biometricTranslatedErrorDesc,
acceptButtonText: { key: "tryAgain" },
type: "danger",
});
if (confirmed) {
// try again
await this.unlockViaBiometrics();
}
this.unlockingViaBiometrics = false;
}
}
togglePassword() {
this.showPassword = !this.showPassword;
const input = document.getElementById(
this.unlockOptions.pin.enabled ? "pin" : "masterPassword",
);
if (this.ngZone.isStable) {
input.focus();
} else {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus());
}
}
private validatePin(): boolean {
if (this.formGroup.invalid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("pinRequired"),
});
return false;
}
return true;
}
private async unlockViaPin() {
if (!this.validatePin()) {
return;
}
const pin = this.formGroup.controls.pin.value;
const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5;
try {
const userKey = await this.pinService.decryptUserKeyWithPin(pin, this.activeAccount.id);
if (userKey) {
await this.setUserKeyAndContinue(userKey);
return; // successfully unlocked
}
// Failure state: invalid PIN or failed decryption
this.invalidPinAttempts++;
// Log user out if they have entered an invalid PIN too many times
if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"),
});
this.messagingService.send("logout");
return;
}
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidPin"),
});
} catch {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("unexpectedError"),
});
}
}
private validateMasterPassword(): boolean {
if (this.formGroup.invalid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return false;
}
return true;
}
private async unlockViaMasterPassword() {
if (!this.validateMasterPassword()) {
return;
}
const masterPassword = this.formGroup.controls.masterPassword.value;
const verification = {
type: VerificationType.MasterPassword,
secret: masterPassword,
} as MasterPasswordVerification;
let passwordValid = false;
let masterPasswordVerificationResponse: MasterPasswordVerificationResponse;
try {
masterPasswordVerificationResponse =
await this.userVerificationService.verifyUserByMasterPassword(
verification,
this.activeAccount.id,
this.activeAccount.email,
);
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(
masterPasswordVerificationResponse.policyOptions,
);
passwordValid = true;
} catch (e) {
this.logService.error(e);
}
if (!passwordValid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterPasswordVerificationResponse.masterKey,
);
await this.setUserKeyAndContinue(userKey, true);
}
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
await this.cryptoService.setUserKey(key, this.activeAccount.id);
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
await this.doContinue(evaluatePasswordAfterUnlock);
}
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
await this.biometricStateService.resetUserPromptCancelled();
this.messagingService.send("unlocked");
if (evaluatePasswordAfterUnlock) {
try {
// If we do not have any saved policies, attempt to load them from the service
if (this.enforcedMasterPasswordOptions == undefined) {
this.enforcedMasterPasswordOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(),
);
}
if (this.requirePasswordChange()) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
await this.router.navigate([this.forcePasswordResetRoute]);
return;
}
} catch (e) {
// Do not prevent unlock if there is an error evaluating policies
this.logService.error(e);
}
}
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
await this.syncService.fullSync(false);
if (this.clientType === "browser") {
const previousUrl = this.lockComponentService.getPreviousUrl();
if (previousUrl) {
await this.router.navigateByUrl(previousUrl);
}
}
// determine success route based on client type
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
await this.router.navigate([successRoute]);
}
/**
* Checks if the master password meets the enforced policy requirements
* If not, returns false
*/
private requirePasswordChange(): boolean {
if (
this.enforcedMasterPasswordOptions == undefined ||
!this.enforcedMasterPasswordOptions.enforceOnLogin
) {
return false;
}
const masterPassword = this.formGroup.controls.masterPassword.value;
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.activeAccount.email,
)?.score;
return !this.policyService.evaluateMasterPassword(
passwordStrength,
masterPassword,
this.enforcedMasterPasswordOptions,
);
}
// -----------------------------------------------------------------------------------------------
// Desktop methods:
// -----------------------------------------------------------------------------------------------
async desktopOnInit() {
// TODO: move this into a WindowService and subscribe to messages via MessageListener service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowHidden":
this.onWindowHidden();
break;
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");
}
private async desktopAutoPromptBiometrics() {
if (!this.unlockOptions?.biometrics?.enabled || this.biometricAsked) {
return;
}
// prevent the biometric prompt from showing if the user has already cancelled it
if (await firstValueFrom(this.biometricStateService.promptCancelled$)) {
return;
}
const windowVisible = await this.lockComponentService.isWindowVisible();
if (windowVisible) {
this.biometricAsked = true;
await this.unlockViaBiometrics();
}
}
onWindowHidden() {
this.showPassword = false;
}
private focusInput() {
if (this.unlockOptions) {
document.getElementById(this.unlockOptions.pin.enabled ? "pin" : "masterPassword")?.focus();
}
}
// -----------------------------------------------------------------------------------------------
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
if (this.clientType === "desktop") {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
}
}
}

View File

@@ -15,6 +15,7 @@ import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-con
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -33,6 +34,7 @@ describe("DefaultSetPasswordJitService", () => {
let apiService: MockProxy<ApiService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
@@ -43,6 +45,7 @@ describe("DefaultSetPasswordJitService", () => {
beforeEach(() => {
apiService = mock<ApiService>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
@@ -53,6 +56,7 @@ describe("DefaultSetPasswordJitService", () => {
sut = new DefaultSetPasswordJitService(
apiService,
cryptoService,
encryptService,
i18nService,
kdfConfigService,
masterPasswordService,
@@ -168,7 +172,7 @@ describe("DefaultSetPasswordJitService", () => {
}
cryptoService.userKey$.mockReturnValue(of(userKey));
cryptoService.rsaEncrypt.mockResolvedValue(userKeyEncString);
encryptService.rsaEncrypt.mockResolvedValue(userKeyEncString);
organizationUserApiService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
undefined,
@@ -210,7 +214,7 @@ describe("DefaultSetPasswordJitService", () => {
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalled();

View File

@@ -14,6 +14,7 @@ import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -29,6 +30,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
protected apiService: ApiService,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -157,7 +159,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
const encryptedUserKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterKeyHash;

View File

@@ -226,7 +226,7 @@ describe("WebAuthnLoginStrategy", () => {
const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey;
encryptService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
encryptService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
@@ -244,9 +244,9 @@ describe("WebAuthnLoginStrategy", () => {
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey,
webAuthnCredentials.prfKey,
);
expect(cryptoService.rsaDecrypt).toHaveBeenCalledTimes(1);
expect(cryptoService.rsaDecrypt).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey.encryptedString,
expect(encryptService.rsaDecrypt).toHaveBeenCalledTimes(1);
expect(encryptService.rsaDecrypt).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey,
mockPrfPrivateKey,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId);
@@ -273,7 +273,7 @@ describe("WebAuthnLoginStrategy", () => {
// Assert
expect(encryptService.decryptToBytes).not.toHaveBeenCalled();
expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled();
expect(encryptService.rsaDecrypt).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
@@ -325,7 +325,7 @@ describe("WebAuthnLoginStrategy", () => {
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
cryptoService.rsaDecrypt.mockResolvedValue(null);
encryptService.rsaDecrypt.mockResolvedValue(null);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);

View File

@@ -4,6 +4,7 @@ import { Jsonify } from "type-fest";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@@ -86,8 +87,8 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
);
// decrypt user key with private key
const userKey = await this.cryptoService.rsaDecrypt(
webAuthnPrfOption.encryptedUserKey.encryptedString,
const userKey = await this.encryptService.rsaDecrypt(
new EncString(webAuthnPrfOption.encryptedUserKey.encryptedString),
privateKey,
);

View File

@@ -6,6 +6,7 @@ import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/maste
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@@ -24,6 +25,7 @@ describe("AuthRequestService", () => {
let masterPasswordService: FakeMasterPasswordService;
const appIdService = mock<AppIdService>();
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
const apiService = mock<ApiService>();
let mockPrivateKey: Uint8Array;
@@ -40,6 +42,7 @@ describe("AuthRequestService", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
stateProvider,
);
@@ -82,7 +85,7 @@ describe("AuthRequestService", () => {
describe("approveOrDenyAuthRequest", () => {
beforeEach(() => {
cryptoService.rsaEncrypt.mockResolvedValue({
encryptService.rsaEncrypt.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);
appIdService.getAppId.mockResolvedValue("APP_ID");
@@ -108,7 +111,7 @@ describe("AuthRequestService", () => {
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
});
it("should use the user key if the master key and hash do not exist", async () => {
@@ -119,7 +122,7 @@ describe("AuthRequestService", () => {
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
});
});
describe("setUserKeyAfterDecryptingSharedUserKey", () => {
@@ -211,7 +214,7 @@ describe("AuthRequestService", () => {
const mockDecryptedUserKeyBytes = new Uint8Array(64);
const mockDecryptedUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes) as UserKey;
cryptoService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyBytes);
encryptService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyBytes);
// Act
const result = await sut.decryptPubKeyEncryptedUserKey(
@@ -220,7 +223,10 @@ describe("AuthRequestService", () => {
);
// Assert
expect(cryptoService.rsaDecrypt).toBeCalledWith(mockPubKeyEncryptedUserKey, mockPrivateKey);
expect(encryptService.rsaDecrypt).toBeCalledWith(
new EncString(mockPubKeyEncryptedUserKey),
mockPrivateKey,
);
expect(result).toEqual(mockDecryptedUserKey);
});
});
@@ -238,7 +244,7 @@ describe("AuthRequestService", () => {
const mockDecryptedMasterKeyHashBytes = new Uint8Array(64);
const mockDecryptedMasterKeyHash = Utils.fromBufferToUtf8(mockDecryptedMasterKeyHashBytes);
cryptoService.rsaDecrypt
encryptService.rsaDecrypt
.mockResolvedValueOnce(mockDecryptedMasterKeyBytes)
.mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes);
@@ -250,14 +256,14 @@ describe("AuthRequestService", () => {
);
// Assert
expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith(
expect(encryptService.rsaDecrypt).toHaveBeenNthCalledWith(
1,
mockPubKeyEncryptedMasterKey,
new EncString(mockPubKeyEncryptedMasterKey),
mockPrivateKey,
);
expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith(
expect(encryptService.rsaDecrypt).toHaveBeenNthCalledWith(
2,
mockPubKeyEncryptedMasterKeyHash,
new EncString(mockPubKeyEncryptedMasterKeyHash),
mockPrivateKey,
);
expect(result.masterKey).toEqual(mockDecryptedMasterKey);

View File

@@ -10,7 +10,9 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
AUTH_REQUEST_DISK_LOCAL,
@@ -44,6 +46,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private accountService: AccountService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private apiService: ApiService,
private stateProvider: StateProvider,
) {
@@ -102,7 +105,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
if (masterKey && masterKeyHash) {
// Only encrypt the master password hash if masterKey exists as
// we won't have a masterKeyHash without a masterKey
encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt(
encryptedMasterKeyHash = await this.encryptService.rsaEncrypt(
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
@@ -112,7 +115,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
keyToEncrypt = userKey.key;
}
const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey);
const encryptedKey = await this.encryptService.rsaEncrypt(keyToEncrypt, pubKey);
const response = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
@@ -161,8 +164,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedUserKey: string,
privateKey: Uint8Array,
): Promise<UserKey> {
const decryptedUserKeyBytes = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedUserKey,
const decryptedUserKeyBytes = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedUserKey),
privateKey,
);
@@ -174,13 +177,13 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedMasterKeyHash: string,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
const decryptedMasterKeyArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKey,
const decryptedMasterKeyArrayBuffer = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedMasterKey),
privateKey,
);
const decryptedMasterKeyHashArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKeyHash,
const decryptedMasterKeyHashArrayBuffer = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedMasterKeyHash),
privateKey,
);

View File

@@ -144,7 +144,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
deviceKeyEncryptedDevicePrivateKey,
] = await Promise.all([
// Encrypt user key with the DevicePublicKey
this.cryptoService.rsaEncrypt(userKey.key, devicePublicKey),
this.encryptService.rsaEncrypt(userKey.key, devicePublicKey),
// Encrypt devicePublicKey with user key
this.encryptService.encrypt(devicePublicKey, userKey),
@@ -206,7 +206,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
);
// Encrypt the brand new user key with the now-decrypted public key for the device
const encryptedNewUserKey = await this.cryptoService.rsaEncrypt(
const encryptedNewUserKey = await this.encryptService.rsaEncrypt(
newUserKey.key,
decryptedDevicePublicKey,
);
@@ -317,8 +317,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
);
// Attempt to decrypt encryptedUserDataKey with devicePrivateKey
const userKey = await this.cryptoService.rsaDecrypt(
encryptedUserKey.encryptedString,
const userKey = await this.encryptService.rsaDecrypt(
new EncString(encryptedUserKey.encryptedString),
devicePrivateKey,
);

View File

@@ -372,7 +372,7 @@ describe("deviceTrustService", () => {
.mockResolvedValue(mockUserKey);
cryptoSvcRsaEncryptSpy = jest
.spyOn(cryptoService, "rsaEncrypt")
.spyOn(encryptService, "rsaEncrypt")
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
encryptServiceEncryptSpy = jest
@@ -577,7 +577,7 @@ describe("deviceTrustService", () => {
.spyOn(encryptService, "decryptToBytes")
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
const rsaDecryptSpy = jest
.spyOn(cryptoService, "rsaDecrypt")
.spyOn(encryptService, "rsaDecrypt")
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
const result = await deviceTrustService.decryptUserKeyWithDeviceKey(
@@ -696,7 +696,7 @@ describe("deviceTrustService", () => {
});
// Mock the encryption of the new user key with the decrypted public key
cryptoService.rsaEncrypt.mockImplementationOnce((data, publicKey) => {
encryptService.rsaEncrypt.mockImplementationOnce((data, publicKey) => {
expect(data.byteLength).toBe(64); // New key should also be 64 bytes
expect(new Uint8Array(data)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';

View File

@@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { UserId } from "../../../../common/src/types/guid";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
@@ -18,6 +19,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let i18nService: MockProxy<I18nService>;
let service: PasswordResetEnrollmentServiceImplementation;
@@ -27,12 +29,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
accountService = mock<AccountService>();
accountService.activeAccount$ = activeAccountSubject;
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
organizationUserApiService = mock<OrganizationUserApiService>();
i18nService = mock<I18nService>();
service = new PasswordResetEnrollmentServiceImplementation(
organizationApiService,
accountService,
cryptoService,
encryptService,
organizationUserApiService,
i18nService,
);
@@ -96,7 +100,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
cryptoService.getUserKey.mockResolvedValue({ key: "key" } as any);
cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
encryptService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
await service.enroll("orgId");
@@ -118,7 +122,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
};
const encryptedKey = { encryptedString: "encryptedString" };
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
encryptService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
await service.enroll("orgId", "userId", { key: "key" } as any);

View File

@@ -4,6 +4,7 @@ import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { CryptoService } from "../../platform/abstractions/crypto.service";
@@ -20,6 +21,7 @@ export class PasswordResetEnrollmentServiceImplementation
protected organizationApiService: OrganizationApiServiceAbstraction,
protected accountService: AccountService,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService,
) {}
@@ -47,7 +49,7 @@ export class PasswordResetEnrollmentServiceImplementation
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
userKey = userKey ?? (await this.cryptoService.getUserKey(userId));
// RSA Encrypt user's userKey.key with organization public key
const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, orgPublicKey);
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, orgPublicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.resetPasswordKey = encryptedKey.encryptedString;

View File

@@ -82,10 +82,10 @@ export function isCardExpired(cipherCard: CardView): boolean {
const parsedYear = parseInt(normalizedYear, 10);
// First day of the next month minus one, to get last day of the card month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
// First day of the next month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
return cardExpiry < now;
return cardExpiry <= now;
}
}

View File

@@ -9,7 +9,6 @@ export enum FeatureFlag {
GeneratorToolsModernization = "generator-tools-modernization",
EnableConsolidatedBilling = "enable-consolidated-billing",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
EnableDeleteProvider = "AC-1218-delete-provider",
ExtensionRefresh = "extension-refresh",
PersistPopupView = "persist-popup-view",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
@@ -21,7 +20,6 @@ export enum FeatureFlag {
EnableTimeThreshold = "PM-5864-dollar-threshold",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
IdpAutoSubmitLogin = "idp-auto-submit-login",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
@@ -34,6 +32,7 @@ export enum FeatureFlag {
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -53,7 +52,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.EnableConsolidatedBilling]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
@@ -65,7 +63,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableTimeThreshold]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
@@ -78,6 +75,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
[FeatureFlag.Pm3478RefactorOrganizationUserApi]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -329,22 +329,6 @@ export abstract class CryptoService {
* @param userId The user's Id
*/
abstract clearKeys(userId?: string): Promise<any>;
/**
* RSA encrypts a value.
* @param data The data to encrypt
* @param publicKey The public key to use for encryption, if not provided, the user's public key will be used
* @returns The encrypted data
* @throws If the given publicKey is a null-ish value.
*/
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
/**
* Decrypts a value using RSA.
* @param encValue The encrypted value to decrypt
* @param privateKey The private key to use for decryption
* @returns The decrypted value
* @throws If the given privateKey is a null-ish value.
*/
abstract rsaDecrypt(encValue: string, privateKey: Uint8Array): Promise<Uint8Array>;
abstract randomNumber(min: number, max: number): Promise<number>;
/**
* Generates a new cipher key

View File

@@ -45,7 +45,7 @@ import { KeyGenerationService } from "../abstractions/key-generation.service";
import { LogService } from "../abstractions/log.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service";
import { KeySuffixOptions, HashPurpose, EncryptionType } from "../enums";
import { KeySuffixOptions, HashPurpose } from "../enums";
import { convertValues } from "../misc/convert-values";
import { EFFLongWordList } from "../misc/wordlist";
import { EncString, EncryptedString } from "../models/domain/enc-string";
@@ -441,7 +441,7 @@ export class CryptoService implements CryptoServiceAbstraction {
const shareKey = await this.keyGenerationService.createKey(512);
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
const publicKey = await firstValueFrom(this.userPublicKey$(userId));
const encShareKey = await this.rsaEncrypt(shareKey.key, publicKey);
const encShareKey = await this.encryptService.rsaEncrypt(shareKey.key, publicKey);
return [encShareKey, shareKey as T];
}
@@ -550,68 +550,6 @@ export class CryptoService implements CryptoServiceAbstraction {
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId);
}
async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString> {
if (publicKey == null) {
throw new Error("'publicKey' is a required parameter and must be non-null");
}
const encBytes = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1");
return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encBytes));
}
async rsaDecrypt(encValue: string, privateKey: Uint8Array): Promise<Uint8Array> {
if (privateKey == null) {
throw new Error("'privateKey' is a required parameter and must be non-null");
}
const headerPieces = encValue.split(".");
let encType: EncryptionType = null;
let encPieces: string[];
if (headerPieces.length === 1) {
encType = EncryptionType.Rsa2048_OaepSha256_B64;
encPieces = [headerPieces[0]];
} else if (headerPieces.length === 2) {
try {
encType = parseInt(headerPieces[0], null);
encPieces = headerPieces[1].split("|");
} catch (e) {
this.logService.error(e);
}
}
switch (encType) {
case EncryptionType.Rsa2048_OaepSha256_B64:
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: // HmacSha256 types are deprecated
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
break;
default:
throw new Error("encType unavailable.");
}
if (encPieces == null || encPieces.length <= 0) {
throw new Error("encPieces unavailable.");
}
const data = Utils.fromB64ToArray(encPieces[0]);
let alg: "sha1" | "sha256" = "sha1";
switch (encType) {
case EncryptionType.Rsa2048_OaepSha256_B64:
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
alg = "sha256";
break;
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
break;
default:
throw new Error("encType unavailable.");
}
return this.cryptoFunctionService.rsaDecrypt(data, privateKey, alg);
}
// EFForg/OpenWireless
// ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js
async randomNumber(min: number, max: number): Promise<number> {

View File

@@ -1,5 +1,7 @@
import { firstValueFrom, map, Subscription, timeout } from "rxjs";
import { BiometricStateService } from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "../../auth/abstractions/account.service";
@@ -11,7 +13,6 @@ import { UserId } from "../../types/guid";
import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
import { BiometricStateService } from "../biometrics/biometric-state.service";
import { Utils } from "../misc/utils";
import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum";
import { TaskSchedulerService } from "../scheduling/task-scheduler.service";

View File

@@ -8,6 +8,7 @@ import {
} from "@bitwarden/auth/common";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricStateService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith, FakeStateProvider } from "../../../spec";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@@ -17,7 +18,6 @@ import { TokenService } from "../../auth/abstractions/token.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { LogService } from "../../platform/abstractions/log.service";
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
import {
VAULT_TIMEOUT,
VAULT_TIMEOUT_ACTION,

View File

@@ -17,6 +17,7 @@ import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { BiometricStateService } from "@bitwarden/key-management";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
@@ -27,7 +28,6 @@ import { TokenService } from "../../auth/abstractions/token.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { LogService } from "../../platform/abstractions/log.service";
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
import { StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";

View File

@@ -36,5 +36,5 @@ export abstract class SendApiService {
renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise<SendFileUploadDataResponse>;
removePassword: (id: string) => Promise<any>;
delete: (id: string) => Promise<any>;
save: (sendData: [Send, EncArrayBuffer]) => Promise<any>;
save: (sendData: [Send, EncArrayBuffer]) => Promise<Send>;
}

View File

@@ -135,11 +135,12 @@ export class SendApiService implements SendApiServiceAbstraction {
return this.apiService.send("DELETE", "/sends/" + id, null, true, false);
}
async save(sendData: [Send, EncArrayBuffer]): Promise<any> {
async save(sendData: [Send, EncArrayBuffer]): Promise<Send> {
const response = await this.upload(sendData);
const data = new SendData(response);
await this.sendService.upsert(data);
return new Send(data);
}
async delete(id: string): Promise<any> {

View File

@@ -1,6 +1,7 @@
import { Observable } from "rxjs";
import { CollectionId, UserId } from "../../types/guid";
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection";
import { TreeNode } from "../models/domain/tree-node";
@@ -13,9 +14,13 @@ export abstract class CollectionService {
encrypt: (model: CollectionView) => Promise<Collection>;
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
/**
* @deprecated This method will soon be made private, use `decryptedCollectionViews$` instead.
* @deprecated This method will soon be made private
* See PM-12375
*/
decryptMany: (collections: Collection[]) => Promise<CollectionView[]>;
decryptMany: (
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
) => Promise<CollectionView[]>;
get: (id: string) => Promise<Collection>;
getAll: () => Promise<Collection[]>;
getAllDecrypted: () => Promise<CollectionView[]>;

View File

@@ -0,0 +1,135 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import {
FakeStateProvider,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../spec";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
import { EncString } from "../../platform/models/domain/enc-string";
import { ContainerService } from "../../platform/services/container.service";
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionData } from "../models/data/collection.data";
import { CollectionService, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.service";
describe("CollectionService", () => {
afterEach(() => {
delete (window as any).bitwardenContainerService;
});
describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => {
// Arrange test collections
const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const collection2 = collectionDataFactory(org2);
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, {
[collection1.id]: collection1,
[collection2.id]: collection2,
});
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new CollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const result = await firstValueFrom(collectionService.decryptedCollections$);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: collection1.id,
name: "DECRYPTED_STRING",
});
expect(result[1]).toMatchObject({
id: collection2.id,
name: "DECRYPTED_STRING",
});
});
it("handles null collection state", async () => {
// Arrange test collections
const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId;
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null);
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new CollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
expect(decryptedCollections.length).toBe(0);
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
expect(encryptedCollections.length).toBe(0);
});
});
});
const mockI18nService = () => {
const i18nService = mock<I18nService>();
i18nService.collator = null; // this is a mock only, avoid use of this object
return i18nService;
};
const mockStateProvider = () => {
const userId = Utils.newGuid() as UserId;
return new FakeStateProvider(mockAccountServiceWith(userId));
};
const mockCryptoService = () => {
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
encryptService.decryptToUtf8
.calledWith(expect.any(EncString), expect.anything())
.mockResolvedValue("DECRYPTED_STRING");
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
return cryptoService;
};
const collectionDataFactory = (orgId: OrganizationId) => {
const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId;
collection.name = makeEncString("ENC_STRING").encryptedString;
return collection;
};

View File

@@ -1,4 +1,4 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@@ -15,6 +15,7 @@ import {
UserKeyDefinition,
} from "../../platform/state";
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service";
import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection";
@@ -22,7 +23,7 @@ import { TreeNode } from "../models/domain/tree-node";
import { CollectionView } from "../models/view/collection.view";
import { ServiceUtils } from "../service-utils";
const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA,
"collections",
{
@@ -31,19 +32,19 @@ const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, C
},
);
const DECRYPTED_COLLECTION_DATA_KEY = DeriveDefinition.from<
Record<CollectionId, CollectionData>,
const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
[Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
CollectionView[],
{ collectionService: CollectionService }
>(ENCRYPTED_COLLECTION_DATA_KEY, {
>(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async (collections: Record<CollectionId, CollectionData>, { collectionService }) => {
const data: Collection[] = [];
for (const id in collections ?? {}) {
const collectionId = id as CollectionId;
data.push(new Collection(collections[collectionId]));
derive: async ([collections, orgKeys], { collectionService }) => {
if (collections == null) {
return [];
}
return await collectionService.decryptMany(data);
const data = Object.values(collections).map((c) => new Collection(c));
return await collectionService.decryptMany(data, orgKeys);
},
});
@@ -68,18 +69,25 @@ export class CollectionService implements CollectionServiceAbstraction {
protected stateProvider: StateProvider,
) {
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
map((collections) => {
const response: Collection[] = [];
for (const id in collections ?? {}) {
response.push(new Collection(collections[id as CollectionId]));
if (collections == null) {
return [];
}
return response;
return Object.values(collections).map((c) => new Collection(c));
}),
);
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe(
switchMap(([userId, collectionData]) =>
combineLatest([of(collectionData), this.cryptoService.orgKeys$(userId)]),
),
);
this.decryptedCollectionDataState = this.stateProvider.getDerived(
this.encryptedCollectionDataState.state$,
encryptedCollectionsWithKeys,
DECRYPTED_COLLECTION_DATA_KEY,
{ collectionService: this },
);
@@ -108,19 +116,24 @@ export class CollectionService implements CollectionServiceAbstraction {
return collection;
}
async decryptMany(collections: Collection[]): Promise<CollectionView[]> {
if (collections == null) {
// TODO: this should be private and orgKeys should be required.
// See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
): Promise<CollectionView[]> {
if (collections == null || collections.length === 0) {
return [];
}
const decCollections: CollectionView[] = [];
const organizationKeys = await firstValueFrom(this.cryptoService.activeUserOrgKeys$);
orgKeys ??= await firstValueFrom(this.cryptoService.activeUserOrgKeys$);
const promises: Promise<any>[] = [];
collections.forEach((collection) => {
promises.push(
collection
.decrypt(organizationKeys[collection.organizationId as OrganizationId])
.decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)),
);
});

View File

@@ -2,8 +2,16 @@
<ng-content select="[slot=start]"></ng-content>
<div class="tw-flex tw-flex-col tw-items-start tw-text-start tw-w-full tw-truncate [&_p]:tw-mb-0">
<div class="tw-text-main tw-text-base tw-w-full tw-truncate">
<ng-content></ng-content>
<div
bitTypography="body2"
class="tw-text-main tw-truncate tw-inline-flex tw-items-center tw-gap-1.5 tw-w-full"
>
<div class="tw-truncate">
<ng-content></ng-content>
</div>
<div class="tw-flex-grow">
<ng-content select="[slot=default-trailing]"></ng-content>
</div>
</div>
<div class="tw-text-muted tw-text-sm tw-w-full tw-truncate">
<ng-content select="[slot=secondary]"></ng-content>

View File

@@ -55,12 +55,13 @@ The content can be a button, anchor, or static container.
`bit-item-content` contains the following slots to help position the content:
| Slot | Description |
| ------------------ | --------------------------------------------------- |
| default | primary text or arbitrary content; fan favorite |
| `slot="secondary"` | supporting text; under the default slot |
| `slot="start"` | commonly an icon or avatar; before the default slot |
| `slot="end"` | commonly an icon; after the default slot |
| Slot | Description |
| ------------------------- | --------------------------------------------------------------------------------------------------------- |
| default | primary text or arbitrary content; fan favorite |
| `slot="secondary"` | supporting text; under the default slot |
| `slot="start"` | commonly an icon or avatar; before the default slot |
| `slot="default-trailing"` | commonly a badge; default content that should not be truncated and is placed right after the default slot |
| `slot="end"` | commonly an icon; placed at the far end after the default slot |
- Note: There is also an `end` slot within `bit-item` itself. Place
[interactive secondary actions](#secondary-actions) there, and place non-interactive content (such
@@ -71,6 +72,7 @@ The content can be a button, anchor, or static container.
<button bit-item-content type="button">
<bit-avatar slot="start" text="Foo"></bit-avatar>
foo@bitwarden.com
<span bitBadge variant="primary" slot="default-trailing">Auto-fill</span>
<ng-container slot="secondary">
<div>Bitwarden.com</div>
<div><em>locked</em></div>

View File

@@ -322,6 +322,30 @@ export const SingleActionList: Story = {
}),
};
export const SingleActionWithBadge: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-item-group aria-label="Single Action With Badge">
<bit-item>
<a bit-item-content href="#">
Foobar
<span bitBadge variant="primary" slot="default-trailing">Auto-fill</span>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
<bit-item>
<a bit-item-content href="#">
Helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo!
<span bitBadge variant="primary" slot="default-trailing">Auto-fill</span>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
</bit-item-group>
`,
}),
};
export const VirtualScrolling: Story = {
render: (_args) => ({
props: {
@@ -329,7 +353,7 @@ export const VirtualScrolling: Story = {
},
template: /*html*/ `
<cdk-virtual-scroll-viewport [itemSize]="46" class="tw-h-[500px]">
<bit-item-group aria-label="Single Action List">
<bit-item-group aria-label="Virtual Scrolling">
<bit-item *cdkVirtualFor="let item of data">
<button bit-item-content>
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>

View File

@@ -0,0 +1,3 @@
# Key management
This lib represents the public API of the Key management team at Bitwarden. Modules are imported using `@bitwarden/key-management`.

View File

@@ -0,0 +1,20 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.libs");
const sharedConfig = require("../../libs/shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
displayName: "libs/key management tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
},
),
};

View File

@@ -0,0 +1,25 @@
{
"name": "@bitwarden/key-management",
"version": "0.0.0",
"description": "Common code used across Bitwarden JavaScript projects.",
"keywords": [
"bitwarden"
],
"author": "Bitwarden Inc.",
"homepage": "https://bitwarden.com",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/clients"
},
"license": "GPL-3.0",
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"build:watch": "npm run clean && tsc -watch"
},
"dependencies": {
"@bitwarden/angular": "file:../angular",
"@bitwarden/common": "file:../common",
"@bitwarden/components": "file:../components"
}
}

View File

@@ -1,11 +1,15 @@
import { firstValueFrom } from "rxjs";
import { makeEncString, trackEmissions } from "../../../spec";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { UserId } from "../../types/guid";
import { EncryptedString } from "../models/domain/enc-string";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { makeEncString, trackEmissions } from "../../../common/spec";
import {
FakeAccountService,
mockAccountServiceWith,
} from "../../../common/spec/fake-account-service";
import { FakeGlobalState, FakeSingleUserState } from "../../../common/spec/fake-state";
import { FakeStateProvider } from "../../../common/spec/fake-state-provider";
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
import {

View File

@@ -1,8 +1,8 @@
import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
import { UserId } from "../../types/guid";
import { EncryptedString, EncString } from "../models/domain/enc-string";
import { ActiveUserState, GlobalState, StateProvider } from "../state";
import { EncryptedString, EncString } from "../../../common/src/platform/models/domain/enc-string";
import { ActiveUserState, GlobalState, StateProvider } from "../../../common/src/platform/state";
import { UserId } from "../../../common/src/types/guid";
import {
BIOMETRIC_UNLOCK_ENABLED,

View File

@@ -1,5 +1,5 @@
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, UserKeyDefinition } from "../state";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { KeyDefinition, UserKeyDefinition } from "@bitwarden/common/platform/state";
import {
BIOMETRIC_UNLOCK_ENABLED,

View File

@@ -1,6 +1,10 @@
import { UserId } from "../../types/guid";
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK, UserKeyDefinition } from "../state";
import { EncryptedString } from "../../../common/src/platform/models/domain/enc-string";
import {
KeyDefinition,
BIOMETRIC_SETTINGS_DISK,
UserKeyDefinition,
} from "../../../common/src/platform/state";
import { UserId } from "../../../common/src/types/guid";
/**
* Indicates whether the user elected to store a biometric key to unlock their vault.
@@ -9,7 +13,7 @@ export const BIOMETRIC_UNLOCK_ENABLED = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"biometricUnlockEnabled",
{
deserializer: (obj) => obj,
deserializer: (obj: any) => obj,
clearOn: [],
},
);
@@ -23,7 +27,7 @@ export const REQUIRE_PASSWORD_ON_START = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"requirePasswordOnStart",
{
deserializer: (value) => value,
deserializer: (value: any) => value,
clearOn: [],
},
);

View File

@@ -0,0 +1,6 @@
export {
BiometricStateService,
DefaultBiometricStateService,
} from "./biometrics/biometric-state.service";
export { BiometricsService } from "./biometrics/biometric.service";
export * from "./biometrics/biometric.state";

View File

@@ -0,0 +1,28 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
return {
display: "none",
appearance: ["-webkit-appearance"],
};
},
});
Object.defineProperty(document, "doctype", {
value: "<!DOCTYPE html>",
});
Object.defineProperty(document.body.style, "transform", {
value: () => {
return {
enumerable: true,
configurable: true,
};
},
});
Object.defineProperty(window, "crypto", {
value: webcrypto,
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../shared/tsconfig.libs",
"include": ["src", "spec"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"files": ["./test.setup.ts"]
}

View File

@@ -19,6 +19,7 @@
"@bitwarden/vault-export-ui": ["../tools/export/vault-export/vault-export-ui/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/importer/ui": ["../importer/src/components"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/send-ui": ["../tools/send/send-ui/src"],
"@bitwarden/node/*": ["../node/src/*"],

View File

@@ -0,0 +1,352 @@
import { EmptyError, Subject, tap } from "rxjs";
import { anyComplete, on, ready } from "./rx";
describe("anyComplete", () => {
it("emits true when its input completes", () => {
const input$ = new Subject<void>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.complete();
expect(emissions).toEqual([true]);
});
it("completes when its input is already complete", () => {
const input = new Subject<void>();
input.complete();
let completed = false;
anyComplete(input).subscribe({ complete: () => (completed = true) });
expect(completed).toBe(true);
});
it("completes when any input completes", () => {
const input$ = new Subject<void>();
const completing$ = new Subject<void>();
let completed = false;
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
completing$.complete();
expect(completed).toBe(true);
});
it("ignores emissions", () => {
const input$ = new Subject<number>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.next(1);
input$.next(2);
input$.complete();
expect(emissions).toEqual([true]);
});
it("forwards errors", () => {
const input$ = new Subject<void>();
const expected = { some: "error" };
let error = null;
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
input$.error(expected);
expect(error).toEqual(expected);
});
});
describe("ready", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: ready$ should be cold
const ready$ = source$.pipe(ready(watch$));
expect(connected).toBe(false);
ready$.subscribe();
expect(connected).toBe(true);
});
it("suppresses source emissions until its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("suppresses source emissions until all watches emit", () => {
const watchA$ = new Subject<void>();
const watchB$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready([watchA$, watchB$]));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// preconditions: no emissions
source$.next(1);
expect(results).toEqual([]);
watchA$.next();
expect(results).toEqual([]);
watchB$.next();
expect(results).toEqual([1]);
});
it("emits the last source emission when its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
source$.next(2);
watch$.next();
expect(results).toEqual([2]);
});
it("emits all source emissions after its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
source$.next(2);
expect(results).toEqual([1, 2]);
});
it("ignores repeated watch emissions", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let completed = false;
ready$.subscribe({ complete: () => (completed = true) });
source$.complete();
expect(completed).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch completes before emitting", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.complete();
expect(error).toBeInstanceOf(EmptyError);
});
});
describe("on", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: on$ should be cold
const on$ = source$.pipe(on(watch$));
expect(connected).toBeFalsy();
on$.subscribe();
expect(connected).toBeTruthy();
});
it("suppresses source emissions until `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
// precondition: on$ should be cold
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("repeats source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
watch$.next();
expect(results).toEqual([1, 1]);
});
it("updates source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("emits a value when `on` emits before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("ignores repeated `on` emissions before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("emits only the latest source emission when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
source$.next(3);
watch$.next();
expect(results).toEqual([1, 3]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
source$.complete();
expect(complete).toBeTruthy();
});
it("completes when its watch completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
watch$.complete();
expect(complete).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
});

View File

@@ -1,4 +1,18 @@
import { map, pipe } from "rxjs";
import {
concat,
concatMap,
connect,
endWith,
first,
ignoreElements,
map,
Observable,
pipe,
race,
ReplaySubject,
takeUntil,
zip,
} from "rxjs";
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
@@ -37,3 +51,86 @@ export function newDefaultEvaluator<Target>() {
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
};
}
/** Create an observable that, once subscribed, emits `true` then completes when
* any input completes. If an input is already complete when the subscription
* occurs, it emits immediately.
* @param watch$ the observable(s) to watch for completion; if an array is passed,
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
* will never complete.
* @returns An observable that emits `true` when any of its inputs
* complete. The observable forwards the first error from its input.
* @remarks This method is particularly useful in combination with `takeUntil` and
* streams that are not guaranteed to complete on their own.
*/
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
if (Array.isArray(watch$)) {
const completes$ = watch$
.filter((w$) => !!w$)
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
const completed$ = race(completes$);
return completed$;
} else {
return watch$.pipe(ignoreElements(), endWith(true));
}
}
/**
* Create an observable that delays the input stream until all watches have
* emitted a value. The watched values are not included in the source stream.
* The last emission from the source is output when all the watches have
* emitted at least once.
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
* `ready` will never emit.
* @returns An observable that emits when the source stream emits. The observable
* errors if one of its watches completes before emitting. It also errors if one
* of its watches errors.
*/
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
return pipe(
connect<T, Observable<T>>((source$) => {
// this subscription is safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
takeUntil(anyComplete(source)),
);
}),
);
}
/**
* Create an observable that emits the latest value of the source stream
* when `watch$` emits. If `watch$` emits before the stream emits, then
* an emission occurs as soon as a value becomes ready.
* @param watch$ the observable that triggers emissions
* @returns An observable that emits when `watch$` emits. The observable
* errors if its source stream errors. It also errors if `on` errors. It
* completes if its watch completes.
*
* @remarks This works like `audit`, but it repeats emissions when
* watch$ fires.
*/
export function on<T>(watch$: Observable<any>) {
return pipe(
connect<T, Observable<T>>((source$) => {
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
return watch$
.pipe(
ready(source),
concatMap(() => source.pipe(first())),
)
.pipe(takeUntil(anyComplete(source)));
}),
);
}

View File

@@ -1,6 +1,6 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
<i *ngIf="!hideIcon" class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>
<a type="button" bitMenuItem (click)="newItemNavigate(sendType.Text)">

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, Input, OnInit } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { firstValueFrom } from "rxjs";
@@ -15,6 +15,8 @@ import { BadgeModule, ButtonModule, MenuModule } from "@bitwarden/components";
imports: [JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule, BadgeModule],
})
export class NewSendDropdownComponent implements OnInit {
@Input() hideIcon: boolean = false;
sendType = SendType;
hasNoPremium = false;

View File

@@ -35,6 +35,7 @@
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyPassword' | i18n"
[disabled]="!sendOptionsForm.get('password').value"
[valueLabel]="'password' | i18n"
[appCopyClick]="sendOptionsForm.get('password').value"
showToast

View File

@@ -1,7 +1,7 @@
<bit-section [formGroup]="sendTextDetailsForm" disableMargin>
<bit-form-field>
<bit-label>{{ "sendTypeTextToShare" | i18n }}</bit-label>
<textarea bitInput id="text" rows="6" formControlName="text"></textarea>
<textarea bitInput id="text" rows="3" formControlName="text"></textarea>
</bit-form-field>
<bit-form-control>
<input bitCheckbox type="checkbox" formControlName="hidden" />

View File

@@ -85,9 +85,14 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
submitBtn?: ButtonComponent;
/**
* Event emitted when the send is saved successfully.
* Event emitted when the send is created successfully.
*/
@Output() sendSaved = new EventEmitter<SendView>();
@Output() onSendCreated = new EventEmitter<SendView>();
/**
* Event emitted when the send is updated successfully.
*/
@Output() onSendUpdated = new EventEmitter<SendView>();
/**
* The original send being edited or cloned. Null for add mode.
@@ -200,22 +205,26 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
return;
}
const sendView = await this.addEditFormService.saveSend(
this.updatedSendView,
this.file,
this.config,
);
if (this.config.mode === "add") {
this.onSendCreated.emit(sendView);
return;
}
if (Utils.isNullOrWhitespace(this.updatedSendView.password)) {
this.updatedSendView.password = null;
}
await this.addEditFormService.saveSend(this.updatedSendView, this.file, this.config);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.config.mode === "edit" || this.config.mode === "partial-edit"
? "editedItem"
: "addedItem",
),
message: this.i18nService.t("editedItem"),
});
this.sendSaved.emit(this.updatedSendView);
this.onSendUpdated.emit(this.updatedSendView);
};
}

View File

@@ -19,6 +19,7 @@ export class DefaultSendFormService implements SendFormService {
async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) {
const sendData = await this.sendService.encrypt(send, file, send.password, null);
return await this.sendApiService.save(sendData);
const newSend = await this.sendApiService.save(sendData);
return await this.decryptSend(newSend);
}
}

View File

@@ -83,7 +83,7 @@ export class SendItemsService {
);
/**
* Observable that indicates whether the user's vault is empty.
* Observable that indicates whether the user's send list is empty.
*/
emptyList$: Observable<boolean> = this._sendList$.pipe(map((sends) => !sends.length));

View File

@@ -1,6 +1,6 @@
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none">
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none tw-pl-0">
<li *ngFor="let attachment of cipher.attachments">
<bit-item>
<bit-item-content>

View File

@@ -3,7 +3,7 @@
buttonType="danger"
size="small"
type="button"
class="tw-border-none"
class="tw-border-transparent"
[appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName"
[bitAction]="delete"
></button>

View File

@@ -1,6 +1,7 @@
import { NgIf } from "@angular/common";
import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
@@ -14,6 +15,7 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { Subject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
@@ -101,6 +103,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
*/
@Output() cipherSaved = new EventEmitter<CipherView>();
private formReadySubject = new Subject<void>();
@Output() formReady = this.formReadySubject.asObservable();
/**
* The original cipher being edited or cloned. Null for add mode.
*/
@@ -173,9 +179,13 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
async init() {
this.loading = true;
// Force change detection so that all child components are destroyed and re-created
this.changeDetectorRef.detectChanges();
this.updatedCipherView = new CipherView();
this.originalCipherView = null;
this.cipherForm.reset();
this.cipherForm = this.formBuilder.group<CipherForm>({});
if (this.config == null) {
return;
@@ -207,6 +217,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
}
this.loading = false;
this.formReadySubject.next();
}
constructor(
@@ -214,6 +225,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
private addEditFormService: CipherFormService,
private toastService: ToastService,
private i18nService: I18nService,
private changeDetectorRef: ChangeDetectorRef,
) {}
/**

View File

@@ -1,22 +0,0 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle>
{{ title }}
</span>
<ng-container bitDialogContent>
<vault-cipher-form-generator
[type]="params.type"
(valueGenerated)="onValueGenerated($event)"
></vault-cipher-form-generator>
</ng-container>
<ng-container bitDialogFooter>
<button
type="button"
bitButton
buttonType="primary"
(click)="selectValue()"
data-testid="select-button"
>
{{ selectButtonText }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -1,125 +0,0 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction";
import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component";
import {
WebVaultGeneratorDialogComponent,
WebVaultGeneratorDialogParams,
WebVaultGeneratorDialogAction,
} from "./web-generator-dialog.component";
describe("WebVaultGeneratorDialogComponent", () => {
let component: WebVaultGeneratorDialogComponent;
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
let dialogRef: MockProxy<DialogRef<any>>;
let mockI18nService: MockProxy<I18nService>;
let passwordOptionsSubject: BehaviorSubject<any>;
let usernameOptionsSubject: BehaviorSubject<any>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
beforeEach(async () => {
dialogRef = mock<DialogRef<any>>();
mockI18nService = mock<I18nService>();
passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]);
usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]);
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPasswordGenerationService.getOptions$.mockReturnValue(
passwordOptionsSubject.asObservable(),
);
mockUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
mockUsernameGenerationService.getOptions$.mockReturnValue(
usernameOptionsSubject.asObservable(),
);
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
providers: [
{
provide: DialogRef,
useValue: dialogRef,
},
{
provide: DIALOG_DATA,
useValue: mockDialogData,
},
{
provide: I18nService,
useValue: mockI18nService,
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockPasswordGenerationService,
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mockUsernameGenerationService,
},
{
provide: CipherFormGeneratorComponent,
useValue: {
passwordOptions$: passwordOptionsSubject.asObservable(),
usernameOptions$: usernameOptionsSubject.asObservable(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("initializes without errors", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("closes the dialog with 'canceled' result when close is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
(component as any).close();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Canceled,
});
});
it("closes the dialog with 'selected' result when selectValue is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
const generatedValue = "generated-value";
component.onValueGenerated(generatedValue);
(component as any).selectValue();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: generatedValue,
});
});
it("updates generatedValue when onValueGenerated is called", () => {
const generatedValue = "new-generated-value";
component.onValueGenerated(generatedValue);
expect((component as any).generatedValue).toBe(generatedValue);
});
});

View File

@@ -1,89 +0,0 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogService } from "@bitwarden/components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { DialogModule } from "../../../../../../libs/components/src/dialog";
export interface WebVaultGeneratorDialogParams {
type: "password" | "username";
}
export interface WebVaultGeneratorDialogResult {
action: WebVaultGeneratorDialogAction;
generatedValue?: string;
}
export enum WebVaultGeneratorDialogAction {
Selected = "selected",
Canceled = "canceled",
}
@Component({
selector: "web-vault-generator-dialog",
templateUrl: "./web-generator-dialog.component.html",
standalone: true,
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule],
})
export class WebVaultGeneratorDialogComponent {
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator");
protected selectButtonText = this.i18nService.t(
this.isPassword ? "useThisPassword" : "useThisUsername",
);
/**
* Whether the dialog is generating a password/passphrase. If false, it is generating a username.
* @protected
*/
protected get isPassword() {
return this.params.type === "password";
}
/**
* The currently generated value.
* @protected
*/
protected generatedValue: string = "";
constructor(
@Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams,
private dialogRef: DialogRef<WebVaultGeneratorDialogResult>,
private i18nService: I18nService,
) {}
/**
* Close the dialog without selecting a value.
*/
protected close = () => {
this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled });
};
/**
* Close the dialog and select the currently generated value.
*/
protected selectValue = () => {
this.dialogRef.close({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: this.generatedValue,
});
};
onValueGenerated(value: string) {
this.generatedValue = value;
}
/**
* Opens the vault generator dialog.
*/
static open(dialogService: DialogService, config: DialogConfig<WebVaultGeneratorDialogParams>) {
return dialogService.open<WebVaultGeneratorDialogResult, WebVaultGeneratorDialogParams>(
WebVaultGeneratorDialogComponent,
{
...config,
},
);
}
}

View File

@@ -1,88 +0,0 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service";
describe("WebCipherFormGenerationService", () => {
let service: WebCipherFormGenerationService;
let dialogService: jest.Mocked<DialogService>;
let closed = of({});
const close = jest.fn();
const dialogRef = {
close,
get closed() {
return closed;
},
} as unknown as DialogRef<unknown, unknown>;
beforeEach(() => {
dialogService = mock<DialogService>();
TestBed.configureTestingModule({
providers: [
WebCipherFormGenerationService,
{ provide: DialogService, useValue: dialogService },
],
});
service = TestBed.inject(WebCipherFormGenerationService);
});
it("creates without error", () => {
expect(service).toBeTruthy();
});
describe("generatePassword", () => {
it("opens the password generator dialog and returns the generated value", async () => {
const generatedValue = "generated-password";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "password" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(result).toBeNull();
});
});
describe("generateUsername", () => {
it("opens the username generator dialog and returns the generated value", async () => {
const generatedValue = "generated-username";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "username" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(result).toBeNull();
});
});
});

View File

@@ -1,40 +0,0 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { CipherFormGenerationService } from "@bitwarden/vault";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
@Injectable()
export class WebCipherFormGenerationService implements CipherFormGenerationService {
private dialogService = inject(DialogService);
async generatePassword(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "password" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
async generateUsername(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "username" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
}

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -44,7 +44,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
AutofillOptionsViewComponent,
],
})
export class CipherViewComponent implements OnInit, OnDestroy {
export class CipherViewComponent implements OnChanges, OnDestroy {
@Input({ required: true }) cipher: CipherView;
/**
@@ -63,7 +63,11 @@ export class CipherViewComponent implements OnInit, OnDestroy {
private folderService: FolderService,
) {}
async ngOnInit() {
async ngOnChanges() {
if (this.cipher == null) {
return;
}
await this.loadCipherData();
this.cardIsExpired = isCardExpired(this.cipher.card);