1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

Merge branch 'main' into PM-18027

This commit is contained in:
Jordan Aasen
2025-02-19 11:08:07 -08:00
committed by GitHub
704 changed files with 23650 additions and 12697 deletions

View File

@@ -7,9 +7,11 @@ import { CollectionService, CollectionView } from "@bitwarden/admin-console/comm
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -45,11 +47,9 @@ export class CollectionsComponent implements OnInit {
}
async load() {
this.cipherDomain = await this.loadCipher();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.collectionIds = this.loadCipherCollections();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
@@ -95,7 +95,8 @@ export class CollectionsComponent implements OnInit {
}
this.cipherDomain.collectionIds = selectedCollectionIds;
try {
this.formPromise = this.saveCollections();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.formPromise = this.saveCollections(activeUserId);
await this.formPromise;
this.onSavedCollections.emit();
this.toastService.showToast({
@@ -114,8 +115,8 @@ export class CollectionsComponent implements OnInit {
}
}
protected loadCipher() {
return this.cipherService.get(this.cipherId);
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected loadCipherCollections() {
@@ -129,7 +130,7 @@ export class CollectionsComponent implements OnInit {
);
}
protected saveCollections() {
return this.cipherService.saveCollectionsWithServer(this.cipherDomain);
protected saveCollections(userId: UserId) {
return this.cipherService.saveCollectionsWithServer(this.cipherDomain, userId);
}
}

View File

@@ -3,7 +3,7 @@
selectedRegion: selectedRegion$ | async,
} as data"
>
<div class="environment-selector-btn">
<div class="tw-text-sm tw-text-muted tw-leading-7 tw-font-normal tw-pl-4">
{{ "accessing" | i18n }}:
<button
type="button"
@@ -13,7 +13,7 @@
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
>
<span class="text-primary">
<span class="tw-text-primary-600 tw-text-sm tw-font-semibold">
<ng-container *ngIf="data.selectedRegion; else fallback">
{{ data.selectedRegion.domain }}
</ng-container>
@@ -35,9 +35,9 @@
(backdropClick)="isOpen = false"
(detach)="close()"
>
<div class="box-content">
<div class="tw-box-content">
<div
class="environment-selector-dialog"
class="tw-bg-background tw-w-full tw-shadow-md tw-p-2 tw-rounded-md"
data-testid="environment-selector-dialog"
[@transformPanel]="'open'"
cdkTrapFocus
@@ -48,7 +48,7 @@
<ng-container *ngFor="let region of availableRegions; let i = index">
<button
type="button"
class="environment-selector-dialog-item"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-pr-2 tw-rounded-md"
(click)="toggle(region.key)"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
[attr.data-testid]="'environment-selector-dialog-item-' + i"
@@ -65,7 +65,7 @@
</ng-container>
<button
type="button"
class="environment-selector-dialog-item"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-pr-2 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-rounded-md"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
data-testid="environment-selector-dialog-item-self-hosted"

View File

@@ -1,342 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { AbstractControl, UntypedFormBuilder, ValidatorFn, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { RegisterResponse } from "@bitwarden/common/auth/models/response/register.response";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { RegisterRequest } from "@bitwarden/common/models/request/register.request";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import {
AllValidationErrors,
FormValidationErrorsService,
} from "../../platform/abstractions/form-validation-errors.service";
import { PasswordColorText } from "../../tools/password-strength/password-strength.component";
import { InputsFieldMatch } from "../validators/inputs-field-match.validator";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@Directive()
export class RegisterComponent extends CaptchaProtectedComponent implements OnInit {
@Input() isInTrialFlow = false;
@Output() createdAccount = new EventEmitter<string>();
showPassword = false;
formPromise: Promise<RegisterResponse>;
referenceData: ReferenceEventRequest;
showTerms = true;
showErrorSummary = false;
passwordStrengthResult: any;
characterMinimumMessage: string;
minimumLength = Utils.minimumPasswordLength;
color: string;
text: string;
formGroup = this.formBuilder.group(
{
email: ["", [Validators.required, Validators.email]],
name: [""],
masterPassword: ["", [Validators.required, Validators.minLength(this.minimumLength)]],
confirmMasterPassword: ["", [Validators.required, Validators.minLength(this.minimumLength)]],
hint: [
null,
[
InputsFieldMatch.validateInputsDoesntMatch(
"masterPassword",
this.i18nService.t("hintEqualsPassword"),
),
],
],
checkForBreaches: [true],
acceptPolicies: [false, [this.acceptPoliciesValidation()]],
},
{
validator: InputsFieldMatch.validateFormInputsMatch(
"masterPassword",
"confirmMasterPassword",
this.i18nService.t("masterPassDoesntMatch"),
),
},
);
protected successRoute = "login";
protected accountCreated = false;
protected captchaBypassToken: string = null;
// allows for extending classes to modify the register request before sending
// currently used by web to add organization invitation details
protected modifyRegisterRequest: (request: RegisterRequest) => Promise<void>;
constructor(
protected formValidationErrorService: FormValidationErrorsService,
protected formBuilder: UntypedFormBuilder,
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected router: Router,
i18nService: I18nService,
protected keyService: KeyService,
protected apiService: ApiService,
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
protected logService: LogService,
protected auditService: AuditService,
protected dialogService: DialogService,
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.showTerms = !platformUtilsService.isSelfHost();
this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength);
}
async ngOnInit() {
// 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.setupCaptcha();
}
async submit(showToast = true) {
let email = this.formGroup.value.email;
email = email.trim().toLowerCase();
let name = this.formGroup.value.name;
name = name === "" ? null : name; // Why do we do this?
const masterPassword = this.formGroup.value.masterPassword;
try {
if (!this.accountCreated) {
const registerResponse = await this.registerAccount(
await this.buildRegisterRequest(email, masterPassword, name),
showToast,
);
if (!registerResponse.successful) {
return;
}
this.captchaBypassToken = registerResponse.captchaBypassToken;
this.accountCreated = true;
}
if (this.isInTrialFlow) {
if (!this.accountCreated) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("trialAccountCreated"),
});
}
const loginResponse = await this.logIn(email, masterPassword, this.captchaBypassToken);
if (loginResponse.captchaRequired) {
return;
}
this.createdAccount.emit(this.formGroup.value.email);
} else {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("newAccountCreated"),
});
// 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.router.navigate([this.successRoute], { queryParams: { email: email } });
}
} catch (e) {
this.logService.error(e);
}
}
togglePassword() {
this.showPassword = !this.showPassword;
}
getStrengthResult(result: any) {
this.passwordStrengthResult = result;
}
getPasswordScoreText(event: PasswordColorText) {
this.color = event.color;
this.text = event.text;
}
private getErrorToastMessage() {
const error: AllValidationErrors = this.formValidationErrorService
.getFormValidationErrors(this.formGroup.controls)
.shift();
if (error) {
switch (error.errorName) {
case "email":
return this.i18nService.t("invalidEmail");
case "inputsDoesntMatchError":
return this.i18nService.t("masterPassDoesntMatch");
case "inputsMatchError":
return this.i18nService.t("hintEqualsPassword");
case "minlength":
return this.i18nService.t("masterPasswordMinlength", Utils.minimumPasswordLength);
default:
return this.i18nService.t(this.errorTag(error));
}
}
return;
}
private errorTag(error: AllValidationErrors): string {
const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1);
return `${error.controlName}${name}`;
}
//validation would be ignored on selfhosted
private acceptPoliciesValidation(): ValidatorFn {
return (control: AbstractControl) => {
const ctrlValue = control.value;
return !ctrlValue && this.showTerms ? { required: true } : null;
};
}
private async validateRegistration(showToast: boolean): Promise<{ isValid: boolean }> {
this.formGroup.markAllAsTouched();
this.showErrorSummary = true;
if (this.formGroup.get("acceptPolicies").hasError("required")) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("acceptPoliciesRequired"),
});
return { isValid: false };
}
//web
if (this.formGroup.invalid && !showToast) {
return { isValid: false };
}
//desktop, browser
if (this.formGroup.invalid && showToast) {
const errorText = this.getErrorToastMessage();
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: errorText,
});
return { isValid: false };
}
const passwordWeak =
this.passwordStrengthResult != null && this.passwordStrengthResult.score < 3;
const passwordLeak =
this.formGroup.controls.checkForBreaches.value &&
(await this.auditService.passwordLeaked(this.formGroup.controls.masterPassword.value)) > 0;
if (passwordWeak && passwordLeak) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return { isValid: false };
}
} else if (passwordWeak) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return { isValid: false };
}
} else if (passwordLeak) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "exposedMasterPassword" },
content: { key: "exposedMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return { isValid: false };
}
}
return { isValid: true };
}
private async buildRegisterRequest(
email: string,
masterPassword: string,
name: string,
): Promise<RegisterRequest> {
const hint = this.formGroup.value.hint;
const kdfConfig = DEFAULT_KDF_CONFIG;
const key = await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
const newUserKey = await this.keyService.makeUserKey(key);
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, key);
const keys = await this.keyService.makeKeyPair(newUserKey[0]);
const request = new RegisterRequest(
email,
name,
masterKeyHash,
hint,
newUserKey[1].encryptedString,
this.referenceData,
this.captchaToken,
kdfConfig.kdfType,
kdfConfig.iterations,
);
request.keys = new KeysRequest(keys[0], keys[1].encryptedString);
if (this.modifyRegisterRequest) {
await this.modifyRegisterRequest(request);
}
return request;
}
private async registerAccount(
request: RegisterRequest,
showToast: boolean,
): Promise<{ successful: boolean; captchaBypassToken?: string }> {
if (!(await this.validateRegistration(showToast)).isValid) {
return { successful: false };
}
this.formPromise = this.apiService.postRegister(request);
try {
const response = await this.formPromise;
return { successful: true, captchaBypassToken: response.captchaBypassToken };
} catch (e) {
if (this.handleCaptchaRequired(e)) {
return { successful: false };
} else {
throw e;
}
}
}
private async logIn(
email: string,
masterPassword: string,
captchaBypassToken: string,
): Promise<{ captchaRequired: boolean }> {
const credentials = new PasswordLoginCredentials(
email,
masterPassword,
captchaBypassToken,
null,
);
const loginResponse = await this.loginStrategyService.logIn(credentials);
if (this.handleCaptchaRequired(loginResponse)) {
return { captchaRequired: true };
}
return { captchaRequired: false };
}
}

View File

@@ -8,6 +8,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -73,10 +74,8 @@ export class ShareComponent implements OnInit, OnDestroy {
}
});
const cipherDomain = await this.cipherService.get(this.cipherId);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
this.cipher = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);
@@ -104,10 +103,8 @@ export class ShareComponent implements OnInit, OnDestroy {
return;
}
const cipherDomain = await this.cipherService.get(this.cipherId);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
const cipherView = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);

View File

@@ -296,7 +296,12 @@ import {
DefaultUserAsymmetricKeysRegenerationApiService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultTaskService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
} from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
@@ -1463,6 +1468,11 @@ const safeProviders: SafeProvider[] = [
useClass: PasswordLoginStrategyData,
deps: [],
}),
safeProvider({
provide: TaskService,
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
}),
];
@NgModule({

View File

@@ -1,62 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { Navigation, Router, UrlTree } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { extensionRefreshRedirect } from "./extension-refresh-redirect";
describe("extensionRefreshRedirect", () => {
let configService: MockProxy<ConfigService>;
let router: MockProxy<Router>;
beforeEach(() => {
configService = mock<ConfigService>();
router = mock<Router>();
TestBed.configureTestingModule({
providers: [
{ provide: ConfigService, useValue: configService },
{ provide: Router, useValue: router },
],
});
});
it("returns true when ExtensionRefresh flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const result = await TestBed.runInInjectionContext(() =>
extensionRefreshRedirect("/redirect")(),
);
expect(result).toBe(true);
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.ExtensionRefresh);
expect(router.parseUrl).not.toHaveBeenCalled();
});
it("returns UrlTree when ExtensionRefresh flag is enabled and preserves query params", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
const urlTree = new UrlTree();
urlTree.queryParams = { test: "test" };
const navigation: Navigation = {
extras: {},
id: 0,
initialUrl: new UrlTree(),
extractedUrl: urlTree,
trigger: "imperative",
previousNavigation: undefined,
};
router.getCurrentNavigation.mockReturnValue(navigation);
await TestBed.runInInjectionContext(() => extensionRefreshRedirect("/redirect")());
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.ExtensionRefresh);
expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], {
queryParams: urlTree.queryParams,
});
});
});

View File

@@ -1,28 +0,0 @@
import { inject } from "@angular/core";
import { UrlTree, Router } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
/**
* Helper function to redirect to a new URL based on the ExtensionRefresh feature flag.
* @param redirectUrl - The URL to redirect to if the ExtensionRefresh flag is enabled.
*/
export function extensionRefreshRedirect(redirectUrl: string): () => Promise<boolean | UrlTree> {
return async () => {
const configService = inject(ConfigService);
const router = inject(Router);
const shouldRedirect = await configService.getFeatureFlag(FeatureFlag.ExtensionRefresh);
if (shouldRedirect) {
const currentNavigation = router.getCurrentNavigation();
const queryParams = currentNavigation?.extractedUrl?.queryParams || {};
// Preserve query params when redirecting as it is likely that the refreshed component
// will be consuming the same query params.
return router.createUrlTree([redirectUrl], { queryParams });
} else {
return true;
}
};
}

View File

@@ -1,32 +0,0 @@
import { Type, inject } from "@angular/core";
import { Route, Routes } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { componentRouteSwap } from "./component-route-swap";
/**
* Helper function to swap between two components based on the ExtensionRefresh feature flag.
* @param defaultComponent - The current non-refreshed component to render.
* @param refreshedComponent - The new refreshed component to render.
* @param options - The shared route options to apply to the default component, and to the alt component if altOptions is not provided.
* @param altOptions - The alt route options to apply to the alt component.
*/
export function extensionRefreshSwap(
defaultComponent: Type<any>,
refreshedComponent: Type<any>,
options: Route,
altOptions?: Route,
): Routes {
return componentRouteSwap(
defaultComponent,
refreshedComponent,
async () => {
const configService = inject(ConfigService);
return configService.getFeatureFlag(FeatureFlag.ExtensionRefresh);
},
options,
altOptions,
);
}

View File

@@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -101,8 +102,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
private personalOwnershipPolicyAppliesToActiveUser: boolean;
private previousCipherId: string;
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform(
@@ -125,7 +124,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected policyService: PolicyService,
protected logService: LogService,
protected passwordRepromptService: PasswordRepromptService,
protected organizationService: OrganizationService,
private organizationService: OrganizationService,
protected dialogService: DialogService,
protected win: Window,
protected datePipe: DatePipe,
@@ -263,12 +262,13 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.title = this.i18nService.t("addItem");
}
const loadedAddEditCipherInfo = await this.loadAddEditCipherInfo();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const loadedAddEditCipherInfo = await this.loadAddEditCipherInfo(activeUserId);
const activeUserId = await firstValueFrom(this.activeUserId$);
if (this.cipher == null) {
if (this.editMode) {
const cipher = await this.loadCipher();
const cipher = await this.loadCipher(activeUserId);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
@@ -420,9 +420,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.id = null;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.encryptCipher(activeUserId);
try {
this.formPromise = this.saveCipher(cipher);
@@ -516,7 +514,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
try {
this.deletePromise = this.deleteCipher();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.deletePromise = this.deleteCipher(activeUserId);
await this.deletePromise;
this.toastService.showToast({
variant: "success",
@@ -542,7 +541,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
try {
this.restorePromise = this.restoreCipher();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.restorePromise = this.restoreCipher(activeUserId);
await this.restorePromise;
this.toastService.showToast({
variant: "success",
@@ -725,8 +725,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
return allCollections.filter((c) => !c.readOnly);
}
protected loadCipher() {
return this.cipherService.get(this.cipherId);
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected encryptCipher(userId: UserId) {
@@ -746,14 +746,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
: this.cipherService.updateWithServer(cipher, orgAdmin);
}
protected deleteCipher() {
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, this.asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, this.asAdmin);
? this.cipherService.deleteWithServer(this.cipher.id, userId, this.asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId, this.asAdmin);
}
protected restoreCipher() {
return this.cipherService.restoreWithServer(this.cipher.id, this.asAdmin);
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId, this.asAdmin);
}
/**
@@ -773,8 +773,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
return this.ownershipOptions[0].value;
}
async loadAddEditCipherInfo(): Promise<boolean> {
const addEditCipherInfo: any = await firstValueFrom(this.cipherService.addEditCipherInfo$);
async loadAddEditCipherInfo(userId: UserId): Promise<boolean> {
const addEditCipherInfo: any = await firstValueFrom(
this.cipherService.addEditCipherInfo$(userId),
);
const loadedSavedInfo = addEditCipherInfo != null;
if (loadedSavedInfo) {
@@ -787,7 +789,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
}
await this.cipherService.setAddEditCipherInfo(null);
await this.cipherService.setAddEditCipherInfo(null, userId);
return loadedSavedInfo;
}
@@ -825,9 +827,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
private async generateSshKey(showNotification: boolean = true) {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");
this.cipher.sshKey.privateKey = sshKey.private_key;
this.cipher.sshKey.publicKey = sshKey.public_key;
this.cipher.sshKey.keyFingerprint = sshKey.key_fingerprint;
this.cipher.sshKey.privateKey = sshKey.privateKey;
this.cipher.sshKey.publicKey = sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = sshKey.fingerprint;
if (showNotification) {
this.toastService.showToast({

View File

@@ -1,10 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -84,9 +85,7 @@ export class AttachmentsComponent implements OnInit {
}
try {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.formPromise = this.saveCipherAttachment(files[0], activeUserId);
this.cipherDomain = await this.formPromise;
this.cipher = await this.cipherDomain.decrypt(
@@ -125,12 +124,11 @@ export class AttachmentsComponent implements OnInit {
}
try {
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id);
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id, activeUserId);
const updatedCipher = await this.deletePromises[attachment.id];
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const cipher = new Cipher(updatedCipher);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
@@ -228,10 +226,8 @@ export class AttachmentsComponent implements OnInit {
}
protected async init() {
this.cipherDomain = await this.loadCipher();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
@@ -287,7 +283,7 @@ export class AttachmentsComponent implements OnInit {
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.accountService.activeAccount$.pipe(getUserId),
);
this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer(
this.cipherDomain,
@@ -301,7 +297,10 @@ export class AttachmentsComponent implements OnInit {
);
// 3. Delete old
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id);
this.deletePromises[attachment.id] = this.deleteCipherAttachment(
attachment.id,
activeUserId,
);
await this.deletePromises[attachment.id];
const foundAttachment = this.cipher.attachments.filter((a2) => a2.id === attachment.id);
if (foundAttachment.length > 0) {
@@ -335,16 +334,16 @@ export class AttachmentsComponent implements OnInit {
}
}
protected loadCipher() {
return this.cipherService.get(this.cipherId);
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected saveCipherAttachment(file: File, userId: UserId) {
return this.cipherService.saveAttachmentWithServer(this.cipherDomain, file, userId);
}
protected deleteCipherAttachment(attachmentId: string) {
return this.cipherService.deleteAttachmentWithServer(this.cipher.id, attachmentId);
protected deleteCipherAttachment(attachmentId: string, userId: UserId) {
return this.cipherService.deleteAttachmentWithServer(this.cipher.id, attachmentId, userId);
}
protected async reupload(attachment: AttachmentView) {

View File

@@ -1,9 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -39,10 +40,8 @@ export class PasswordHistoryComponent implements OnInit {
}
protected async init() {
const cipher = await this.cipherService.get(this.cipherId);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.cipherService.get(this.cipherId, activeUserId);
const decCipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);

View File

@@ -2,10 +2,13 @@
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
import { BehaviorSubject, Subject, firstValueFrom, from, map, switchMap, takeUntil } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -41,11 +44,20 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
) {
this.cipherService.cipherViews$.pipe(takeUntilDestroyed()).subscribe((ciphers) => {
void this.doSearch(ciphers);
this.loaded = true;
});
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.cipherService.cipherViews$(userId).pipe(map((ciphers) => ({ userId, ciphers }))),
),
takeUntilDestroyed(),
)
.subscribe(({ userId, ciphers }) => {
void this.doSearch(ciphers, userId);
this.loaded = true;
});
}
ngOnInit(): void {
@@ -122,10 +134,16 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
protected async doSearch(indexedCiphers?: CipherView[]) {
indexedCiphers = indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$));
protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) {
// Get userId from activeAccount if not provided from parent stream
if (!userId) {
userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
}
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$);
indexedCiphers =
indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId)));
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId));
if (failedCiphers != null && failedCiphers.length > 0) {
indexedCiphers = [...failedCiphers, ...indexedCiphers];
}

View File

@@ -11,13 +11,14 @@ import {
OnInit,
Output,
} from "@angular/core";
import { filter, firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { filter, firstValueFrom, map, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -29,11 +30,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherType, FieldType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
@@ -65,7 +66,6 @@ export class ViewComponent implements OnDestroy, OnInit {
showPrivateKey: boolean;
canAccessPremium: boolean;
showPremiumRequiredTotp: boolean;
showUpgradeRequiredTotp: boolean;
totpCode: string;
totpCodeFormatted: string;
totpDash: number;
@@ -80,9 +80,6 @@ export class ViewComponent implements OnDestroy, OnInit {
private previousCipherId: string;
private passwordReprompted = false;
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
private destroyed$ = new Subject<void>();
get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform(
@@ -145,41 +142,35 @@ export class ViewComponent implements OnDestroy, OnInit {
async load() {
this.cleanUp();
const activeUserId = await firstValueFrom(this.activeUserId$);
// Grab individual cipher from `cipherViews$` for the most up-to-date information
this.cipherService.cipherViews$
.pipe(
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipher = await firstValueFrom(
this.cipherService.cipherViews$(activeUserId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)),
filter((cipher) => !!cipher),
takeUntil(this.destroyed$),
)
.subscribe((cipher) => {
this.cipher = cipher;
});
),
);
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationId;
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.showUpgradeRequiredTotp =
this.cipher.login.totp && this.cipher.organizationId && !this.cipher.organizationUseTotp;
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(activeUserId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp = this.cipher.organizationId
? this.cipher.organizationUseTotp
: this.canAccessPremium;
if (this.cipher.type === CipherType.Login && this.cipher.login.totp && canGenerateTotp) {
if (
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium)
) {
await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
await this.totpTick(interval);
@@ -254,7 +245,8 @@ export class ViewComponent implements OnDestroy, OnInit {
}
try {
await this.deleteCipher();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.deleteCipher(activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -276,7 +268,8 @@ export class ViewComponent implements OnDestroy, OnInit {
}
try {
await this.restoreCipher();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.restoreCipher(activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -384,7 +377,8 @@ export class ViewComponent implements OnDestroy, OnInit {
}
if (cipherId) {
await this.cipherService.updateLastLaunchedDate(cipherId);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.updateLastLaunchedDate(cipherId, activeUserId);
}
this.platformUtilsService.launchUri(uri.launchUri);
@@ -502,14 +496,14 @@ export class ViewComponent implements OnDestroy, OnInit {
a.downloading = false;
}
protected deleteCipher() {
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id)
: this.cipherService.softDeleteWithServer(this.cipher.id);
? this.cipherService.deleteWithServer(this.cipher.id, userId)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId);
}
protected restoreCipher() {
return this.cipherService.restoreWithServer(this.cipher.id);
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId);
}
protected async promptPassword() {
@@ -528,7 +522,6 @@ export class ViewComponent implements OnDestroy, OnInit {
this.showCardNumber = false;
this.showCardCode = false;
this.passwordReprompted = false;
this.destroyed$.next();
if (this.totpInterval) {
clearInterval(this.totpInterval);
}

View File

@@ -11,6 +11,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
@@ -30,8 +31,6 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
private readonly collapsedGroupings$: Observable<Set<string>> =
this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c)));
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
constructor(
protected organizationService: OrganizationService,
protected folderService: FolderService,
@@ -63,7 +62,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
}
buildNestedFolders(organizationId?: string): Observable<DynamicTreeNode<FolderView>> {
const transformation = async (storedFolders: FolderView[]) => {
const transformation = async (storedFolders: FolderView[], userId: UserId) => {
let folders: FolderView[];
// If no org or "My Vault" is selected, show all folders
@@ -71,7 +70,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
folders = storedFolders;
} else {
// Otherwise, show only folders that have ciphers from the selected org and the "no folder" folder
const ciphers = await this.cipherService.getAllDecrypted();
const ciphers = await this.cipherService.getAllDecrypted(userId);
const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId);
folders = storedFolders.filter(
(f) => orgCiphers.some((oc) => oc.folderId == f.id) || f.id == null,
@@ -85,9 +84,13 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
});
};
return this.activeUserId$.pipe(
switchMap((userId) => this.folderService.folderViews$(userId)),
mergeMap((folders) => from(transformation(folders))),
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.folderService
.folderViews$(userId)
.pipe(mergeMap((folders) => from(transformation(folders, userId)))),
),
);
}
@@ -131,7 +134,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
}
async getFolderNested(id: string): Promise<TreeNode<FolderView>> {
const activeUserId = await firstValueFrom(this.activeUserId$);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const folders = await this.getAllFoldersNested(
await firstValueFrom(this.folderService.folderViews$(activeUserId)),
);

View File

@@ -6,10 +6,6 @@ import * as stories from "./anon-layout-wrapper.stories";
# Anon Layout Wrapper
NOTE: These stories will treat "Light & Dark" mode as "Light" mode. This is done to avoid a bug with
the way that we render the same component twice in the same iframe and how that interacts with the
`router-outlet`.
## Anon Layout Wrapper Component
The auth owned `AnonLayoutWrapperComponent` orchestrates routing configuration data and feeds it

View File

@@ -1,5 +1,5 @@
<ng-container *ngIf="loading">
<div class="text-center">
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"

View File

@@ -56,13 +56,6 @@ describe("DefaultLoginComponentService", () => {
expect(service).toBeTruthy();
});
describe("getOrgPolicies", () => {
it("returns null", async () => {
const result = await service.getOrgPolicies();
expect(result).toBeNull();
});
});
describe("isLoginWithPasskeySupported", () => {
it("returns true when clientType is Web", () => {
service["clientType"] = ClientType.Web;

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LoginComponentService, PasswordPolicies } from "@bitwarden/auth/angular";
import { LoginComponentService } from "@bitwarden/auth/angular";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@@ -23,10 +23,6 @@ export class DefaultLoginComponentService implements LoginComponentService {
protected ssoLoginService: SsoLoginServiceAbstraction,
) {}
async getOrgPolicies(): Promise<PasswordPolicies | null> {
return null;
}
isLoginWithPasskeySupported(): boolean {
return this.clientType === ClientType.Web;
}

View File

@@ -23,7 +23,7 @@ export abstract class LoginComponentService {
* Gets the organization policies if there is an organization invite.
* - Used by: Web
*/
getOrgPolicies: () => Promise<PasswordPolicies | null>;
getOrgPoliciesFromOrgInvite?: () => Promise<PasswordPolicies | null>;
/**
* Indicates whether login with passkey is supported on the given client

View File

@@ -12,6 +12,7 @@ import {
PasswordLoginCredentials,
} from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
@@ -30,6 +31,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -43,7 +45,7 @@ import {
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import { VaultIcon, WaveIcon } from "../icons";
import { LoginComponentService } from "./login-component.service";
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
const BroadcasterSubscriptionId = "LoginComponent";
@@ -72,7 +74,6 @@ export class LoginComponent implements OnInit, OnDestroy {
@ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined;
private destroy$ = new Subject<void>();
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions | undefined = undefined;
readonly Icons = { WaveIcon, VaultIcon };
clientType: ClientType;
@@ -97,11 +98,6 @@ export class LoginComponent implements OnInit, OnDestroy {
return this.formGroup.controls.email;
}
// Web properties
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined;
policies: Policy[] | undefined;
showResetPasswordAutoEnrollWarning = false;
// Desktop properties
deferFocus: boolean | null = null;
@@ -281,18 +277,39 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
// User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId);
this.loginEmailService.clearValues();
// Determine where to send the user next
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
this.loginEmailService.clearValues();
await this.router.navigate(["update-temp-password"]);
return;
}
// If none of the above cases are true, proceed with login...
await this.evaluatePassword();
// TODO: PM-18269 - evaluate if we can combine this with the
// password evaluation done in the password login strategy.
// If there's an existing org invite, use it to get the org's password policies
// so we can evaluate the MP against the org policies
if (this.loginComponentService.getOrgPoliciesFromOrgInvite) {
const orgPolicies: PasswordPolicies | null =
await this.loginComponentService.getOrgPoliciesFromOrgInvite();
this.loginEmailService.clearValues();
if (orgPolicies) {
// Since we have retrieved the policies, we can go ahead and set them into state for future use
// e.g., the update-password page currently only references state for policy data and
// doesn't fallback to pulling them from the server like it should if they are null.
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
orgPolicies.enforcedPasswordPolicyOptions,
);
if (isPasswordChangeRequired) {
await this.router.navigate(["update-password"]);
return;
}
}
}
if (this.clientType === ClientType.Browser) {
await this.router.navigate(["/tabs/vault"]);
@@ -310,54 +327,51 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.loginComponentService.launchSsoBrowserWindow(email, clientId);
}
protected async evaluatePassword(): Promise<void> {
/**
* Checks if the master password meets the enforced policy requirements
* and if the user is required to change their password.
*/
private async isPasswordChangeRequiredByOrgPolicy(
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions,
): Promise<boolean> {
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 (enforcedPasswordPolicyOptions == undefined) {
return false;
}
if (this.requirePasswordChange()) {
await this.router.navigate(["update-password"]);
return;
// Note: we deliberately do not check enforcedPasswordPolicyOptions.enforceOnLogin
// as existing users who are logging in after getting an org invite should
// always be forced to set a password that meets the org's policy.
// Org Invite -> Registration also works this way for new BW users as well.
const masterPassword = this.formGroup.controls.masterPassword.value;
// Return false if masterPassword is null/undefined since this is only evaluated after successful login
if (!masterPassword) {
return false;
}
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.formGroup.value.email ?? undefined,
)?.score;
return !this.policyService.evaluateMasterPassword(
passwordStrength,
masterPassword,
enforcedPasswordPolicyOptions,
);
} catch (e) {
// Do not prevent unlock if there is an error evaluating policies
this.logService.error(e);
return false;
}
}
/**
* 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;
// Return false if masterPassword is null/undefined since this is only evaluated after successful login
if (!masterPassword) {
return false;
}
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.formGroup.value.email ?? undefined,
)?.score;
return !this.policyService.evaluateMasterPassword(
passwordStrength,
masterPassword,
this.enforcedMasterPasswordOptions,
);
private async setPoliciesIntoState(userId: UserId, policies: Policy[]): Promise<void> {
const policiesData: { [id: string]: PolicyData } = {};
policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
await this.policyService.replace(policiesData, userId);
}
protected async startAuthRequestLogin(): Promise<void> {
@@ -528,12 +542,6 @@ export class LoginComponent implements OnInit, OnDestroy {
}
private async defaultOnInit(): Promise<void> {
// If there's an existing org invite, use it to get the password policies
const orgPolicies = await this.loginComponentService.getOrgPolicies();
this.policies = orgPolicies?.policies;
this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled ?? false;
let paramEmailIsSet = false;
const params = await firstValueFrom(this.activatedRoute.queryParams);

View File

@@ -367,7 +367,7 @@ export class SsoComponent implements OnInit {
codeVerifier,
redirectUri,
orgSsoIdentifier,
email,
email ?? undefined,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const authResult = await this.formPromise;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock } from "jest-mock-extended";
import { ReplaySubject, combineLatest, map } from "rxjs";
import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid";
@@ -55,7 +55,7 @@ export class FakeAccountService implements AccountService {
}),
);
}
get nextUpAccount$() {
get nextUpAccount$(): Observable<Account> {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);

View File

@@ -225,9 +225,9 @@ export class FakeStateProvider implements StateProvider {
async setUserState<T>(
userKeyDefinition: UserKeyDefinition<T>,
value: T,
value: T | null,
userId?: UserId,
): Promise<[UserId, T]> {
): Promise<[UserId, T | null]> {
await this.mock.setUserState(userKeyDefinition, value, userId);
if (userId) {
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];

View File

@@ -131,9 +131,9 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
): Promise<T> {
): Promise<T | null> {
options = populateOptionsWithDefault(options);
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
const combinedDependencies =
@@ -206,9 +206,9 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
): Promise<[UserId, T]> {
): Promise<[UserId, T | null]> {
options = populateOptionsWithDefault(options);
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
const combinedDependencies =

View File

@@ -11,7 +11,7 @@ export abstract class SsoLoginServiceAbstraction {
* @see https://datatracker.ietf.org/doc/html/rfc7636
* @returns The code verifier used for SSO.
*/
abstract getCodeVerifier: () => Promise<string>;
abstract getCodeVerifier: () => Promise<string | null>;
/**
* Sets the code verifier used for SSO.
*
@@ -31,7 +31,7 @@ export abstract class SsoLoginServiceAbstraction {
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
* @returns The SSO state.
*/
abstract getSsoState: () => Promise<string>;
abstract getSsoState: () => Promise<string | null>;
/**
* Sets the value of the SSO state.
*
@@ -48,7 +48,7 @@ export abstract class SsoLoginServiceAbstraction {
* Do not use this value outside of the SSO login flow.
* @returns The user's organization identifier.
*/
abstract getOrganizationSsoIdentifier: () => Promise<string>;
abstract getOrganizationSsoIdentifier: () => Promise<string | null>;
/**
* Sets the value of the user's organization sso identifier.
*
@@ -61,7 +61,7 @@ export abstract class SsoLoginServiceAbstraction {
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
* @returns The user's email.
*/
abstract getSsoEmail: () => Promise<string>;
abstract getSsoEmail: () => Promise<string | null>;
/**
* Sets the user's email.
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.

View File

@@ -7,4 +7,5 @@ export enum TwoFactorProviderType {
Remember = 5,
OrganizationDuo = 6,
WebAuthn = 7,
RecoveryCode = 8,
}

View File

@@ -1,9 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import {
FakeAccountService,
makeStaticByteArray,

View File

@@ -11,9 +11,8 @@ import {
switchMap,
} from "rxjs";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { StateService } from "../../platform/abstractions/state.service";
import { MessageSender } from "../../platform/messaging";

View File

@@ -3,10 +3,8 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";

View File

@@ -4,13 +4,11 @@ import { matches, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";

View File

@@ -2,10 +2,8 @@ import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationData } from "../../admin-console/models/data/organization.data";

View File

@@ -2,10 +2,8 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";

View File

@@ -6,10 +6,8 @@ import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";

View File

@@ -73,7 +73,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL);
}
getCodeVerifier(): Promise<string> {
getCodeVerifier(): Promise<string | null> {
return firstValueFrom(this.codeVerifierState.state$);
}
@@ -81,7 +81,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.codeVerifierState.update((_) => codeVerifier);
}
getSsoState(): Promise<string> {
getSsoState(): Promise<string | null> {
return firstValueFrom(this.ssoState.state$);
}
@@ -89,7 +89,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.ssoState.update((_) => ssoState);
}
getOrganizationSsoIdentifier(): Promise<string> {
getOrganizationSsoIdentifier(): Promise<string | null> {
return firstValueFrom(this.orgSsoIdentifierState.state$);
}
@@ -97,7 +97,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.orgSsoIdentifierState.update((_) => organizationIdentifier);
}
getSsoEmail(): Promise<string> {
getSsoEmail(): Promise<string | null> {
return firstValueFrom(this.ssoEmailState.state$);
}

View File

@@ -12,11 +12,9 @@ import {
BiometricsStatus,
KdfConfig,
KeyService,
KdfConfigService,
} from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "../../../../../key-management/src/abstractions/kdf-config.service";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { VaultTimeoutSettingsService } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";

View File

@@ -8,7 +8,6 @@ export enum FeatureFlag {
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
PM14505AdminConsoleIntegrationPage = "pm-14505-admin-console-integration-page",
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
/* Autofill */
@@ -51,6 +50,7 @@ export enum FeatureFlag {
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
NewDeviceVerification = "new-device-verification",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
RecoveryCodeLogin = "pm-17128-recovery-code-login",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -69,7 +69,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.PM14505AdminConsoleIntegrationPage]: FALSE,
[FeatureFlag.LimitItemDeletion]: FALSE,
/* Autofill */
@@ -112,6 +111,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
[FeatureFlag.NewDeviceVerification]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.RecoveryCodeLogin]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -2,11 +2,9 @@
// @ts-strict-ignore
import { firstValueFrom, map, timeout } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { BiometricStateService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";

View File

@@ -8,9 +8,8 @@ import { Observable, of, switchMap } from "rxjs";
import { getHostname, parse } from "tldts";
import { Merge } from "type-fest";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../abstractions/i18n.service";

View File

@@ -65,7 +65,6 @@ export default class Domain {
key: SymmetricCryptoKey = null,
objectContext: string = "No Domain Context",
): Promise<T> {
const promises = [];
const self: any = this;
for (const prop in map) {
@@ -74,27 +73,15 @@ export default class Domain {
continue;
}
(function (theProp) {
const p = Promise.resolve()
.then(() => {
const mapProp = map[theProp] || theProp;
if (self[mapProp]) {
return self[mapProp].decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
return null;
})
.then((val: any) => {
(viewModel as any)[theProp] = val;
});
promises.push(p);
})(prop);
const mapProp = map[prop] || prop;
if (self[mapProp]) {
(viewModel as any)[prop] = await self[mapProp].decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
}
await Promise.all(promises);
return viewModel;
}
@@ -121,22 +108,20 @@ export default class Domain {
_: Constructor<TThis> = this.constructor as Constructor<TThis>,
objectContext: string = "No Domain Context",
): Promise<DecryptedObject<TThis, TEncryptedKeys>> {
const promises = [];
const decryptedObjects = [];
for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
promises.push(
this.decryptProperty(
prop,
value,
key,
encryptService,
`Property: ${prop.toString()}; ObjectContext: ${objectContext}`,
),
const decrypted = await this.decryptProperty(
prop,
value,
key,
encryptService,
`Property: ${prop.toString()}; ObjectContext: ${objectContext}`,
);
decryptedObjects.push(decrypted);
}
const decryptedObjects = await Promise.all(promises);
const decryptedObject = decryptedObjects.reduce(
(acc, obj) => {
return { ...acc, ...obj };

View File

@@ -1,8 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { makeEncString, makeStaticByteArray } from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";

View File

@@ -151,11 +151,15 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
await this.syncService.syncUpsertCipher(
notification.payload as SyncCipherNotification,
notification.type === NotificationType.SyncCipherUpdate,
payloadUserId,
);
break;
case NotificationType.SyncCipherDelete:
case NotificationType.SyncLoginDelete:
await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification);
await this.syncService.syncDeleteCipher(
notification.payload as SyncCipherNotification,
payloadUserId,
);
break;
case NotificationType.SyncFolderCreate:
case NotificationType.SyncFolderUpdate:

View File

@@ -1,6 +1,5 @@
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
export class ContainerService {

View File

@@ -3,7 +3,8 @@ import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { Account, AccountService } from "../../../auth/abstractions/account.service";
import { mockAccountServiceWith } from "../../../../spec";
import { Account } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
@@ -46,7 +47,6 @@ describe("FidoAuthenticatorService", () => {
let userInterface!: MockProxy<Fido2UserInterfaceService<ParentWindowReference>>;
let userInterfaceSession!: MockProxy<Fido2UserInterfaceSession>;
let syncService!: MockProxy<SyncService>;
let accountService!: MockProxy<AccountService>;
let authenticator!: Fido2AuthenticatorService<ParentWindowReference>;
let windowReference!: ParentWindowReference;
@@ -58,7 +58,7 @@ describe("FidoAuthenticatorService", () => {
syncService = mock<SyncService>({
activeUserLastSync$: () => of(new Date()),
});
accountService = mock<AccountService>();
const accountService = mockAccountServiceWith("testId" as UserId);
authenticator = new Fido2AuthenticatorService(
cipherService,
userInterface,

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { getUserId } from "../../../auth/services/account.service";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
@@ -145,10 +146,10 @@ export class Fido2AuthenticatorService<ParentWindowReference>
try {
keyPair = await createKeyPair();
pubKeyDer = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const encrypted = await this.cipherService.get(cipherId);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.accountService.activeAccount$.pipe(getUserId),
);
const encrypted = await this.cipherService.get(cipherId, activeUserId);
cipher = await encrypted.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(encrypted, activeUserId),
@@ -309,7 +310,7 @@ export class Fido2AuthenticatorService<ParentWindowReference>
if (selectedFido2Credential.counter > 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.accountService.activeAccount$.pipe(getUserId),
);
const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId);
await this.cipherService.updateWithServer(encrypted);
@@ -400,7 +401,8 @@ export class Fido2AuthenticatorService<ParentWindowReference>
return [];
}
const ciphers = await this.cipherService.getAllDecrypted();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const ciphers = await this.cipherService.getAllDecrypted(activeUserId);
return ciphers
.filter(
(cipher) =>
@@ -421,7 +423,8 @@ export class Fido2AuthenticatorService<ParentWindowReference>
return [];
}
const ciphers = await this.cipherService.getAllDecrypted();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const ciphers = await this.cipherService.getAllDecrypted(activeUserId);
return ciphers.filter(
(cipher) =>
!cipher.isDeleted &&
@@ -438,7 +441,8 @@ export class Fido2AuthenticatorService<ParentWindowReference>
}
private async findCredentialsByRp(rpId: string): Promise<CipherView[]> {
const ciphers = await this.cipherService.getAllDecrypted();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const ciphers = await this.cipherService.getAllDecrypted(activeUserId);
return ciphers.filter(
(cipher) =>
!cipher.isDeleted &&

View File

@@ -1,8 +1,7 @@
import { mock } from "jest-mock-extended";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { DefaultKeyService } from "../../../../key-management/src/key.service";
import { DefaultKeyService } from "@bitwarden/key-management";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { UserKey } from "../../types/key";

View File

@@ -1,6 +1,5 @@
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "../../types/guid";
import { KeySuffixOptions } from "../enums";

View File

@@ -18,13 +18,13 @@ export interface GlobalState<T> {
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/
update: <TCombine>(
configureState: (state: T, dependency: TCombine) => T,
configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
) => Promise<T>;
) => Promise<T | null>;
/**
* An observable stream of this state, the first emission of this will be the current state on disk
* and subsequent updates will be from an update to that state.
*/
state$: Observable<T>;
state$: Observable<T | null>;
}

View File

@@ -16,7 +16,7 @@ export class DefaultSingleUserState<T>
extends StateBase<T, UserKeyDefinition<T>>
implements SingleUserState<T>
{
readonly combinedState$: Observable<CombinedState<T>>;
readonly combinedState$: Observable<CombinedState<T | null>>;
constructor(
readonly userId: UserId,

View File

@@ -54,9 +54,9 @@ export class DefaultStateProvider implements StateProvider {
async setUserState<T>(
userKeyDefinition: UserKeyDefinition<T>,
value: T,
value: T | null,
userId?: UserId,
): Promise<[UserId, T]> {
): Promise<[UserId, T | null]> {
if (userId) {
return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)];
} else {

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Observable,
ReplaySubject,
defer,
filter,
firstValueFrom,
merge,
Observable,
ReplaySubject,
share,
switchMap,
tap,
@@ -22,7 +22,7 @@ import {
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DebugOptions } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
import { getStoredValue } from "./util";
@@ -36,7 +36,7 @@ type KeyDefinitionRequirements<T> = {
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
private updatePromise: Promise<T>;
readonly state$: Observable<T>;
readonly state$: Observable<T | null>;
constructor(
protected readonly key: StorageKey,
@@ -86,9 +86,9 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> {
): Promise<T | null> {
options = populateOptionsWithDefault(options);
if (this.updatePromise != null) {
await this.updatePromise;
@@ -96,17 +96,16 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
try {
this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise;
return newState;
return await this.updatePromise;
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine>,
): Promise<T> {
): Promise<T | null> {
const currentState = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
@@ -122,7 +121,7 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
return newState;
}
protected async doStorageSave(newState: T, oldState: T) {
protected async doStorageSave(newState: T | null, oldState: T) {
if (this.keyDefinition.debug.enableUpdateLogging) {
this.logService.info(
`Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`,

View File

@@ -60,9 +60,9 @@ export abstract class StateProvider {
*/
abstract setUserState<T>(
keyDefinition: UserKeyDefinition<T>,
value: T,
value: T | null,
userId?: UserId,
): Promise<[UserId, T]>;
): Promise<[UserId, T | null]>;
/** @see{@link ActiveUserStateProvider.get} */
abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;

View File

@@ -12,7 +12,7 @@ export interface UserState<T> {
readonly state$: Observable<T | null>;
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
readonly combinedState$: Observable<CombinedState<T>>;
readonly combinedState$: Observable<CombinedState<T | null>>;
}
export const activeMarker: unique symbol = Symbol("active");
@@ -38,9 +38,9 @@ export interface ActiveUserState<T> extends UserState<T> {
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/
readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,
configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
) => Promise<[UserId, T]>;
) => Promise<[UserId, T | null]>;
}
export interface SingleUserState<T> extends UserState<T> {
@@ -58,7 +58,7 @@ export interface SingleUserState<T> extends UserState<T> {
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/
readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,
configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
) => Promise<T>;
) => Promise<T | null>;
}

View File

@@ -129,12 +129,18 @@ export abstract class CoreSyncService implements SyncService {
return this.syncCompleted(false);
}
async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise<boolean> {
async syncUpsertCipher(
notification: SyncCipherNotification,
isEdit: boolean,
userId: UserId,
): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus >= AuthenticationStatus.Locked) {
try {
let shouldUpdate = true;
const localCipher = await this.cipherService.get(notification.id);
const localCipher = await this.cipherService.get(notification.id, userId);
if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) {
shouldUpdate = false;
}
@@ -182,7 +188,7 @@ export abstract class CoreSyncService implements SyncService {
}
} catch (e) {
if (e != null && e.statusCode === 404 && isEdit) {
await this.cipherService.delete(notification.id);
await this.cipherService.delete(notification.id, userId);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
@@ -191,10 +197,12 @@ export abstract class CoreSyncService implements SyncService {
return this.syncCompleted(false);
}
async syncDeleteCipher(notification: SyncCipherNotification): Promise<boolean> {
async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.cipherService.delete(notification.id);
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus >= AuthenticationStatus.Locked) {
await this.cipherService.delete(notification.id, userId);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}

View File

@@ -7,6 +7,7 @@ import {
CollectionData,
CollectionDetailsResponse,
} from "@bitwarden/admin-console/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
@@ -14,9 +15,6 @@ import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/co
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "../../../../auth/src/common/types";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { ApiService } from "../../abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";

View File

@@ -62,8 +62,9 @@ export abstract class SyncService {
abstract syncUpsertCipher(
notification: SyncCipherNotification,
isEdit: boolean,
userId: UserId,
): Promise<boolean>;
abstract syncDeleteCipher(notification: SyncFolderNotification): Promise<boolean>;
abstract syncDeleteCipher(notification: SyncFolderNotification, userId: UserId): Promise<boolean>;
abstract syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean>;
abstract syncDeleteSend(notification: SyncSendNotification): Promise<boolean>;
}

View File

@@ -32,7 +32,11 @@ export class DefaultThemeStateService implements ThemeStateService {
map(([theme, isExtensionRefresh]) => {
// The extension refresh should not allow for Nord or SolarizedDark
// Default the user to their system theme
if (isExtensionRefresh && [ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)) {
if (
isExtensionRefresh &&
theme != null &&
[ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)
) {
return ThemeType.System;
}

View File

@@ -6,6 +6,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service";
import { EventUploadService } from "../../abstractions/event/event-upload.service";
@@ -46,7 +47,7 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION);
if (!(await this.shouldUpdate(null, eventType, ciphers))) {
if (!(await this.shouldUpdate(userId, null, eventType, ciphers))) {
return;
}
@@ -91,7 +92,7 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION);
if (!(await this.shouldUpdate(organizationId, eventType, undefined, cipherId))) {
if (!(await this.shouldUpdate(userId, organizationId, eventType, undefined, cipherId))) {
return;
}
@@ -113,18 +114,18 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
}
/** Verifies if the event collection should be updated for the provided information
* @param userId the active user's id
* @param cipherId the cipher for the event
* @param organizationId the organization for the event
*/
private async shouldUpdate(
userId: UserId,
organizationId: string = null,
eventType: EventType = null,
ciphers: CipherView[] = [],
cipherId?: string,
): Promise<boolean> {
const cipher$ = from(this.cipherService.get(cipherId));
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const cipher$ = from(this.cipherService.get(cipherId, userId));
const orgIds$ = this.organizationService
.organizations$(userId)

View File

@@ -6,11 +6,8 @@ import {
FakeUserDecryptionOptions as UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { BiometricStateService } from "@bitwarden/key-management";
import { BiometricStateService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { FakeAccountService, mockAccountServiceWith, FakeStateProvider } from "../../../spec";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";

View File

@@ -19,11 +19,8 @@ import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { BiometricStateService } from "@bitwarden/key-management";
import { BiometricStateService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";

View File

@@ -19,57 +19,70 @@ import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
cipherViews$: Observable<CipherView[]>;
ciphers$: Observable<Record<CipherId, CipherData>>;
localData$: Observable<Record<CipherId, LocalData>>;
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>;
/**
* An observable monitoring the add/edit cipher info saved to memory.
*/
addEditCipherInfo$: Observable<AddEditCipherInfo>;
abstract addEditCipherInfo$(userId: UserId): Observable<AddEditCipherInfo>;
/**
* Observable that emits an array of cipherViews that failed to decrypt. Does not emit until decryption has completed.
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$: Observable<CipherView[]>;
clearCache: (userId?: string) => Promise<void>;
encrypt: (
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]>;
abstract clearCache(userId: UserId): Promise<void>;
abstract encrypt(
model: CipherView,
userId: UserId,
keyForEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher?: Cipher,
) => Promise<Cipher>;
encryptFields: (fieldsModel: FieldView[], key: SymmetricCryptoKey) => Promise<Field[]>;
encryptField: (fieldModel: FieldView, key: SymmetricCryptoKey) => Promise<Field>;
get: (id: string) => Promise<Cipher>;
getAll: () => Promise<Cipher[]>;
getAllDecrypted: () => Promise<CipherView[]>;
getAllDecryptedForGrouping: (groupingId: string, folder?: boolean) => Promise<CipherView[]>;
getAllDecryptedForUrl: (
): Promise<Cipher>;
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
abstract get(id: string, userId: UserId): Promise<Cipher>;
abstract getAll(userId: UserId): Promise<Cipher[]>;
abstract getAllDecrypted(userId: UserId): Promise<CipherView[]>;
abstract getAllDecryptedForGrouping(
groupingId: string,
userId: UserId,
folder?: boolean,
): Promise<CipherView[]>;
abstract getAllDecryptedForUrl(
url: string,
userId: UserId,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
) => Promise<CipherView[]>;
filterCiphersForUrl: (
): Promise<CipherView[]>;
abstract filterCiphersForUrl(
ciphers: CipherView[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
) => Promise<CipherView[]>;
getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
): Promise<CipherView[]>;
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
/**
* Gets ciphers belonging to the specified organization that the user has explicit collection level access to.
* Ciphers that are not assigned to any collections are only included for users with admin access.
*/
getManyFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
getLastUsedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getLastLaunchedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getNextCipherForUrl: (url: string) => Promise<CipherView>;
updateLastUsedIndexForUrl: (url: string) => void;
updateLastUsedDate: (id: string) => Promise<void>;
updateLastLaunchedDate: (id: string) => Promise<void>;
saveNeverDomain: (domain: string) => Promise<void>;
abstract getManyFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
abstract getLastUsedForUrl(
url: string,
userId: UserId,
autofillOnPageLoad: boolean,
): Promise<CipherView>;
abstract getLastLaunchedForUrl(
url: string,
userId: UserId,
autofillOnPageLoad: boolean,
): Promise<CipherView>;
abstract getNextCipherForUrl(url: string, userId: UserId): Promise<CipherView>;
abstract updateLastUsedIndexForUrl(url: string): void;
abstract updateLastUsedDate(id: string, userId: UserId): Promise<void>;
abstract updateLastLaunchedDate(id: string, userId: UserId): Promise<void>;
abstract saveNeverDomain(domain: string): Promise<void>;
/**
* Create a cipher with the server
*
@@ -78,7 +91,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* @returns A promise that resolves to the created cipher
*/
createWithServer: (cipher: Cipher, orgAdmin?: boolean) => Promise<Cipher>;
abstract createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher>;
/**
* Update a cipher with the server
* @param cipher The cipher to update
@@ -87,88 +100,105 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* @returns A promise that resolves to the updated cipher
*/
updateWithServer: (cipher: Cipher, orgAdmin?: boolean, isNotClone?: boolean) => Promise<Cipher>;
shareWithServer: (
abstract updateWithServer(
cipher: Cipher,
orgAdmin?: boolean,
isNotClone?: boolean,
): Promise<Cipher>;
abstract shareWithServer(
cipher: CipherView,
organizationId: string,
collectionIds: string[],
userId: UserId,
) => Promise<Cipher>;
shareManyWithServer: (
): Promise<Cipher>;
abstract shareManyWithServer(
ciphers: CipherView[],
organizationId: string,
collectionIds: string[],
userId: UserId,
) => Promise<any>;
saveAttachmentWithServer: (
): Promise<any>;
abstract saveAttachmentWithServer(
cipher: Cipher,
unencryptedFile: any,
userId: UserId,
admin?: boolean,
) => Promise<Cipher>;
saveAttachmentRawWithServer: (
): Promise<Cipher>;
abstract saveAttachmentRawWithServer(
cipher: Cipher,
filename: string,
data: ArrayBuffer,
userId: UserId,
admin?: boolean,
) => Promise<Cipher>;
): Promise<Cipher>;
/**
* Save the collections for a cipher with the server
*
* @param cipher The cipher to save collections for
* @param userId The user ID
*
* @returns A promise that resolves when the collections have been saved
*/
saveCollectionsWithServer: (cipher: Cipher) => Promise<Cipher>;
abstract saveCollectionsWithServer(cipher: Cipher, userId: UserId): Promise<Cipher>;
/**
* Save the collections for a cipher with the server as an admin.
* Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally).
* @param cipher
*/
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<Cipher>;
abstract saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId
* @param userId
* @param cipherIds
* @param collectionIds
* @param removeCollections - If true, the collections will be removed from the ciphers, otherwise they will be added
*/
bulkUpdateCollectionsWithServer: (
abstract bulkUpdateCollectionsWithServer(
orgId: OrganizationId,
userId: UserId,
cipherIds: CipherId[],
collectionIds: CollectionId[],
removeCollections: boolean,
) => Promise<void>;
): Promise<void>;
/**
* Update the local store of CipherData with the provided data. Values are upserted into the existing store.
*
* @param cipher The cipher data to upsert. Can be a single CipherData object or an array of CipherData objects.
* @returns A promise that resolves to a record of updated cipher store, keyed by their cipher ID. Returns all ciphers, not just those updated
*/
upsert: (cipher: CipherData | CipherData[]) => Promise<Record<CipherId, CipherData>>;
replace: (ciphers: { [id: string]: CipherData }, userId: UserId) => Promise<any>;
clear: (userId?: string) => Promise<void>;
moveManyWithServer: (ids: string[], folderId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;
deleteWithServer: (id: string, asAdmin?: boolean) => Promise<any>;
deleteManyWithServer: (ids: string[], asAdmin?: boolean) => Promise<any>;
deleteAttachment: (id: string, revisionDate: string, attachmentId: string) => Promise<CipherData>;
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<CipherData>;
sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number;
sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number;
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;
softDelete: (id: string | string[]) => Promise<any>;
softDeleteWithServer: (id: string, asAdmin?: boolean) => Promise<any>;
softDeleteManyWithServer: (ids: string[], asAdmin?: boolean) => Promise<any>;
restore: (
abstract upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>>;
abstract replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any>;
abstract clear(userId?: string): Promise<void>;
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
abstract delete(id: string | string[], userId: UserId): Promise<any>;
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
abstract deleteAttachment(
id: string,
revisionDate: string,
attachmentId: string,
userId: UserId,
): Promise<CipherData>;
abstract deleteAttachmentWithServer(
id: string,
attachmentId: string,
userId: UserId,
): Promise<CipherData>;
abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number;
abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number;
abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number;
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
abstract restore(
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
) => Promise<any>;
restoreWithServer: (id: string, asAdmin?: boolean) => Promise<any>;
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
getKeyForCipherKeyDecryption: (cipher: Cipher, userId: UserId) => Promise<any>;
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
userId: UserId,
): Promise<any>;
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract restoreManyWithServer(ids: string[], orgId?: string): Promise<void>;
abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<any>;
abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise<void>;
/**
* Returns user ciphers re-encrypted with the new user key.
* @param originalUserKey the original user key
@@ -177,11 +207,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @throws Error if new user key is null
* @returns a list of user ciphers that have been re-encrypted with the new user key
*/
getRotatedData: (
abstract getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<CipherWithIdRequest[]>;
getNextCardCipher: () => Promise<CipherView>;
getNextIdentityCipher: () => Promise<CipherView>;
): Promise<CipherWithIdRequest[]>;
abstract getNextCardCipher(userId: UserId): Promise<CipherView>;
abstract getNextIdentityCipher(userId: UserId): Promise<CipherView>;
}

View File

@@ -0,0 +1,8 @@
const VaultMessages = {
HasBwInstalled: "hasBwInstalled",
checkBwInstalled: "checkIfBWExtensionInstalled",
OpenPopup: "openPopup",
PopupOpened: "popupOpened",
} as const;
export { VaultMessages };

View File

@@ -1,6 +0,0 @@
const VaultOnboardingMessages = {
HasBwInstalled: "hasBwInstalled",
checkBwInstalled: "checkIfBWExtensionInstalled",
} as const;
export { VaultOnboardingMessages };

View File

@@ -12,7 +12,10 @@ import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherData } from "../data/cipher.data";
import { LocalData } from "../data/local.data";
import { AttachmentView } from "../view/attachment.view";
import { CipherView } from "../view/cipher.view";
import { FieldView } from "../view/field.view";
import { PasswordHistoryView } from "../view/password-history.view";
import { Attachment } from "./attachment";
import { Card } from "./card";
@@ -136,6 +139,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
const keyBytes = await encryptService.decryptToBytes(
this.key,
encKey,
@@ -198,44 +202,28 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
if (this.attachments != null && this.attachments.length > 0) {
const attachments: any[] = [];
await this.attachments.reduce((promise, attachment) => {
return promise
.then(() => {
return attachment.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey);
})
.then((decAttachment) => {
attachments.push(decAttachment);
});
}, Promise.resolve());
const attachments: AttachmentView[] = [];
for (const attachment of this.attachments) {
attachments.push(
await attachment.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey),
);
}
model.attachments = attachments;
}
if (this.fields != null && this.fields.length > 0) {
const fields: any[] = [];
await this.fields.reduce((promise, field) => {
return promise
.then(() => {
return field.decrypt(this.organizationId, encKey);
})
.then((decField) => {
fields.push(decField);
});
}, Promise.resolve());
const fields: FieldView[] = [];
for (const field of this.fields) {
fields.push(await field.decrypt(this.organizationId, encKey));
}
model.fields = fields;
}
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
const passwordHistory: any[] = [];
await this.passwordHistory.reduce((promise, ph) => {
return promise
.then(() => {
return ph.decrypt(this.organizationId, encKey);
})
.then((decPh) => {
passwordHistory.push(decPh);
});
}, Promise.resolve());
const passwordHistory: PasswordHistoryView[] = [];
for (const ph of this.passwordHistory) {
passwordHistory.push(await ph.decrypt(this.organizationId, encKey));
}
model.passwordHistory = passwordHistory;
}

View File

@@ -382,8 +382,16 @@ describe("Cipher Service", () => {
Cipher1: cipher1,
Cipher2: cipher2,
});
cipherService.cipherViews$ = decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers)));
cipherService.failedToDecryptCiphers$ = failedCiphers = new BehaviorSubject<CipherView[]>([]);
jest
.spyOn(cipherService, "cipherViews$")
.mockImplementation((userId: UserId) =>
decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers))),
);
failedCiphers = new BehaviorSubject<CipherView[]>([]);
jest
.spyOn(cipherService, "failedToDecryptCiphers$")
.mockImplementation((userId: UserId) => failedCiphers);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Cipher Key");

View File

@@ -37,7 +37,7 @@ import Domain from "../../platform/models/domain/domain-base";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ActiveUserState, StateProvider } from "../../platform/state";
import { StateProvider } from "../../platform/state";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey, UserKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
@@ -97,33 +97,6 @@ export class CipherService implements CipherServiceAbstraction {
*/
private forceCipherViews$: Subject<CipherView[]> = new Subject<CipherView[]>();
localData$: Observable<Record<CipherId, LocalData>>;
ciphers$: Observable<Record<CipherId, CipherData>>;
/**
* Observable that emits an array of decrypted ciphers for the active user.
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
*
* A `null` value indicates that the latest encrypted ciphers have not been decrypted yet and that
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
*
*/
cipherViews$: Observable<CipherView[] | null>;
addEditCipherInfo$: Observable<AddEditCipherInfo>;
/**
* Observable that emits an array of cipherViews that failed to decrypt. Does not emit until decryption has completed.
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$: Observable<CipherView[]>;
private localDataState: ActiveUserState<Record<CipherId, LocalData>>;
private encryptedCiphersState: ActiveUserState<Record<CipherId, CipherData>>;
private decryptedCiphersState: ActiveUserState<Record<CipherId, CipherView>>;
private failedToDecryptCiphersState: ActiveUserState<CipherView[]>;
private addEditCipherInfoState: ActiveUserState<AddEditCipherInfo>;
constructor(
private keyService: KeyService,
private domainSettingsService: DomainSettingsService,
@@ -138,30 +111,49 @@ export class CipherService implements CipherServiceAbstraction {
private configService: ConfigService,
private stateProvider: StateProvider,
private accountService: AccountService,
) {
this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY);
this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS);
this.decryptedCiphersState = this.stateProvider.getActive(DECRYPTED_CIPHERS);
this.failedToDecryptCiphersState = this.stateProvider.getActive(FAILED_DECRYPTED_CIPHERS);
this.addEditCipherInfoState = this.stateProvider.getActive(ADD_EDIT_CIPHER_INFO_KEY);
) {}
this.localData$ = this.localDataState.state$.pipe(map((data) => data ?? {}));
this.ciphers$ = this.encryptedCiphersState.state$.pipe(map((ciphers) => ciphers ?? {}));
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
return this.localDataState(userId).state$.pipe(map((data) => data ?? {}));
}
// Decrypted ciphers depend on both ciphers and local data and need to be updated when either changes
this.cipherViews$ = combineLatest([this.encryptedCiphersState.state$, this.localData$]).pipe(
/**
* Observable that emits an object of encrypted ciphers for the active user.
*/
ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>> {
return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {}));
}
/**
* Observable that emits an array of decrypted ciphers for the active user.
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
*
* A `null` value indicates that the latest encrypted ciphers have not been decrypted yet and that
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
*/
cipherViews$(userId: UserId): Observable<CipherView[] | null> {
return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe(
filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted())),
switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted(userId))),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
this.failedToDecryptCiphers$ = this.failedToDecryptCiphersState.state$.pipe(
addEditCipherInfo$(userId: UserId): Observable<AddEditCipherInfo> {
return this.addEditCipherInfoState(userId).state$;
}
/**
* Observable that emits an array of cipherViews that failed to decrypt. Does not emit until decryption has completed.
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]> {
return this.failedToDecryptCiphersState(userId).state$.pipe(
filter((ciphers) => ciphers != null),
switchMap((ciphers) => merge(this.forceCipherViews$, of(ciphers))),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
}
async setDecryptedCipherCache(value: CipherView[], userId: UserId) {
@@ -212,7 +204,7 @@ export class CipherService implements CipherServiceAbstraction {
): Promise<Cipher> {
if (model.id != null) {
if (originalCipher == null) {
originalCipher = await this.get(model.id);
originalCipher = await this.get(model.id, userId);
}
if (originalCipher != null) {
await this.updateModelfromExistingCipher(model, originalCipher, userId);
@@ -366,22 +358,22 @@ export class CipherService implements CipherServiceAbstraction {
return ph;
}
async get(id: string): Promise<Cipher> {
const ciphers = await firstValueFrom(this.ciphers$);
async get(id: string, userId: UserId): Promise<Cipher> {
const ciphers = await firstValueFrom(this.ciphers$(userId));
// eslint-disable-next-line
if (ciphers == null || !ciphers.hasOwnProperty(id)) {
return null;
}
const localData = await firstValueFrom(this.localData$);
const localData = await firstValueFrom(this.localData$(userId));
const cipherId = id as CipherId;
return new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null);
}
async getAll(): Promise<Cipher[]> {
const localData = await firstValueFrom(this.localData$);
const ciphers = await firstValueFrom(this.ciphers$);
async getAll(userId: UserId): Promise<Cipher[]> {
const localData = await firstValueFrom(this.localData$(userId));
const ciphers = await firstValueFrom(this.ciphers$(userId));
const response: Cipher[] = [];
for (const id in ciphers) {
// eslint-disable-next-line
@@ -399,33 +391,27 @@ export class CipherService implements CipherServiceAbstraction {
* @deprecated Use `cipherViews$` observable instead
*/
@sequentialize(() => "getAllDecrypted")
async getAllDecrypted(): Promise<CipherView[]> {
const decCiphers = await this.getDecryptedCiphers();
async getAllDecrypted(userId: UserId): Promise<CipherView[]> {
const decCiphers = await this.getDecryptedCiphers(userId);
if (decCiphers != null && decCiphers.length !== 0) {
await this.reindexCiphers();
return await this.getDecryptedCiphers();
}
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$);
if (activeUserId == null) {
return [];
await this.reindexCiphers(userId);
return await this.getDecryptedCiphers(userId);
}
const [newDecCiphers, failedCiphers] = await this.decryptCiphers(
await this.getAll(),
activeUserId,
await this.getAll(userId),
userId,
);
await this.setDecryptedCipherCache(newDecCiphers, activeUserId);
await this.setFailedDecryptedCiphers(failedCiphers, activeUserId);
await this.setDecryptedCipherCache(newDecCiphers, userId);
await this.setFailedDecryptedCiphers(failedCiphers, userId);
return newDecCiphers;
}
private async getDecryptedCiphers() {
private async getDecryptedCiphers(userId: UserId) {
return Object.values(
await firstValueFrom(this.decryptedCiphersState.state$.pipe(map((c) => c ?? {}))),
await firstValueFrom(this.decryptedCiphersState(userId).state$.pipe(map((c) => c ?? {}))),
);
}
@@ -491,18 +477,21 @@ export class CipherService implements CipherServiceAbstraction {
);
}
private async reindexCiphers() {
const userId = await this.stateService.getUserId();
private async reindexCiphers(userId: UserId) {
const reindexRequired =
this.searchService != null &&
((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId;
if (reindexRequired) {
await this.searchService.indexCiphers(await this.getDecryptedCiphers(), userId);
await this.searchService.indexCiphers(await this.getDecryptedCiphers(userId), userId);
}
}
async getAllDecryptedForGrouping(groupingId: string, folder = true): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
async getAllDecryptedForGrouping(
groupingId: string,
userId: UserId,
folder = true,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted(userId);
return ciphers.filter((cipher) => {
if (cipher.isDeleted) {
@@ -524,10 +513,11 @@ export class CipherService implements CipherServiceAbstraction {
async getAllDecryptedForUrl(
url: string,
userId: UserId,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
const ciphers = await this.getAllDecrypted(userId);
return await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch);
}
@@ -569,8 +559,11 @@ export class CipherService implements CipherServiceAbstraction {
});
}
private async getAllDecryptedCiphersOfType(type: CipherType[]): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
private async getAllDecryptedCiphersOfType(
type: CipherType[],
userId: UserId,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted(userId);
return ciphers
.filter((cipher) => cipher.deletedDate == null && type.includes(cipher.type))
.sort((a, b) => this.sortCiphersByLastUsedThenName(a, b));
@@ -613,23 +606,31 @@ export class CipherService implements CipherServiceAbstraction {
return decCiphers;
}
async getLastUsedForUrl(url: string, autofillOnPageLoad = false): Promise<CipherView> {
return this.getCipherForUrl(url, true, false, autofillOnPageLoad);
async getLastUsedForUrl(
url: string,
userId: UserId,
autofillOnPageLoad = false,
): Promise<CipherView> {
return this.getCipherForUrl(url, userId, true, false, autofillOnPageLoad);
}
async getLastLaunchedForUrl(url: string, autofillOnPageLoad = false): Promise<CipherView> {
return this.getCipherForUrl(url, false, true, autofillOnPageLoad);
async getLastLaunchedForUrl(
url: string,
userId: UserId,
autofillOnPageLoad = false,
): Promise<CipherView> {
return this.getCipherForUrl(url, userId, false, true, autofillOnPageLoad);
}
async getNextCipherForUrl(url: string): Promise<CipherView> {
return this.getCipherForUrl(url, false, false, false);
async getNextCipherForUrl(url: string, userId: UserId): Promise<CipherView> {
return this.getCipherForUrl(url, userId, false, false, false);
}
async getNextCardCipher(): Promise<CipherView> {
async getNextCardCipher(userId: UserId): Promise<CipherView> {
const cacheKey = "cardCiphers";
if (!this.sortedCiphersCache.isCached(cacheKey)) {
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Card]);
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Card], userId);
if (!ciphers?.length) {
return null;
}
@@ -640,11 +641,11 @@ export class CipherService implements CipherServiceAbstraction {
return this.sortedCiphersCache.getNext(cacheKey);
}
async getNextIdentityCipher(): Promise<CipherView> {
async getNextIdentityCipher(userId: UserId): Promise<CipherView> {
const cacheKey = "identityCiphers";
if (!this.sortedCiphersCache.isCached(cacheKey)) {
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Identity]);
const ciphers = await this.getAllDecryptedCiphersOfType([CipherType.Identity], userId);
if (!ciphers?.length) {
return null;
}
@@ -659,9 +660,8 @@ export class CipherService implements CipherServiceAbstraction {
this.sortedCiphersCache.updateLastUsedIndex(url);
}
async updateLastUsedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
async updateLastUsedDate(id: string, userId: UserId): Promise<void> {
let ciphersLocalData = await firstValueFrom(this.localData$(userId));
if (!ciphersLocalData) {
ciphersLocalData = {};
@@ -676,9 +676,9 @@ export class CipherService implements CipherServiceAbstraction {
};
}
await this.localDataState.update(() => ciphersLocalData);
await this.localDataState(userId).update(() => ciphersLocalData);
const decryptedCipherCache = await this.getDecryptedCiphers();
const decryptedCipherCache = await this.getDecryptedCiphers(userId);
if (!decryptedCipherCache) {
return;
}
@@ -693,9 +693,8 @@ export class CipherService implements CipherServiceAbstraction {
await this.setDecryptedCiphers(decryptedCipherCache, userId);
}
async updateLastLaunchedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
async updateLastLaunchedDate(id: string, userId: UserId): Promise<void> {
let ciphersLocalData = await firstValueFrom(this.localData$(userId));
if (!ciphersLocalData) {
ciphersLocalData = {};
@@ -707,9 +706,9 @@ export class CipherService implements CipherServiceAbstraction {
lastUsedDate: currentTime,
};
await this.localDataState.update(() => ciphersLocalData);
await this.localDataState(userId).update(() => ciphersLocalData);
const decryptedCipherCache = await this.getDecryptedCiphers();
const decryptedCipherCache = await this.getDecryptedCiphers(userId);
if (!decryptedCipherCache) {
return;
}
@@ -914,13 +913,13 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(cData);
}
async saveCollectionsWithServer(cipher: Cipher): Promise<Cipher> {
async saveCollectionsWithServer(cipher: Cipher, userId: UserId): Promise<Cipher> {
const request = new CipherCollectionsRequest(cipher.collectionIds);
const response = await this.apiService.putCipherCollections(cipher.id, request);
// The response will now check for an unavailable value. This value determines whether
// the user still has Can Manage access to the item after updating.
if (response.unavailable) {
await this.delete(cipher.id);
await this.delete(cipher.id, userId);
return;
}
const data = new CipherData(response.cipher);
@@ -944,6 +943,7 @@ export class CipherService implements CipherServiceAbstraction {
*/
async bulkUpdateCollectionsWithServer(
orgId: OrganizationId,
userId: UserId,
cipherIds: CipherId[],
collectionIds: CollectionId[],
removeCollections: boolean = false,
@@ -958,7 +958,7 @@ export class CipherService implements CipherServiceAbstraction {
await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false);
// Update the local state
const ciphers = await firstValueFrom(this.ciphers$);
const ciphers = await firstValueFrom(this.ciphers$(userId));
for (const id of cipherIds) {
const cipher = ciphers[id];
@@ -975,7 +975,7 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.encryptedCiphersState.update(() => ciphers);
await this.encryptedCiphersState(userId).update(() => ciphers);
}
async upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>> {
@@ -1016,10 +1016,10 @@ export class CipherService implements CipherServiceAbstraction {
await this.clearCache(userId);
}
async moveManyWithServer(ids: string[], folderId: string): Promise<any> {
async moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any> {
await this.apiService.putMoveCiphers(new CipherBulkMoveRequest(ids, folderId));
let ciphers = await firstValueFrom(this.ciphers$);
let ciphers = await firstValueFrom(this.ciphers$(userId));
if (ciphers == null) {
ciphers = {};
}
@@ -1032,11 +1032,11 @@ export class CipherService implements CipherServiceAbstraction {
});
await this.clearCache();
await this.encryptedCiphersState.update(() => ciphers);
await this.encryptedCiphersState(userId).update(() => ciphers);
}
async delete(id: string | string[]): Promise<any> {
const ciphers = await firstValueFrom(this.ciphers$);
async delete(id: string | string[], userId: UserId): Promise<any> {
const ciphers = await firstValueFrom(this.ciphers$(userId));
if (ciphers == null) {
return;
}
@@ -1054,35 +1054,36 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.encryptedCiphersState.update(() => ciphers);
await this.encryptedCiphersState(userId).update(() => ciphers);
}
async deleteWithServer(id: string, asAdmin = false): Promise<any> {
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
if (asAdmin) {
await this.apiService.deleteCipherAdmin(id);
} else {
await this.apiService.deleteCipher(id);
}
await this.delete(id);
await this.delete(id, userId);
}
async deleteManyWithServer(ids: string[], asAdmin = false): Promise<any> {
async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
const request = new CipherBulkDeleteRequest(ids);
if (asAdmin) {
await this.apiService.deleteManyCiphersAdmin(request);
} else {
await this.apiService.deleteManyCiphers(request);
}
await this.delete(ids);
await this.delete(ids, userId);
}
async deleteAttachment(
id: string,
revisionDate: string,
attachmentId: string,
userId: UserId,
): Promise<CipherData> {
let ciphers = await firstValueFrom(this.ciphers$);
let ciphers = await firstValueFrom(this.ciphers$(userId));
const cipherId = id as CipherId;
// eslint-disable-next-line
if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[cipherId].attachments == null) {
@@ -1100,7 +1101,7 @@ export class CipherService implements CipherServiceAbstraction {
ciphers[cipherId].revisionDate = revisionDate;
await this.clearCache();
await this.encryptedCiphersState.update(() => {
await this.encryptedCiphersState(userId).update(() => {
if (ciphers == null) {
ciphers = {};
}
@@ -1110,7 +1111,11 @@ export class CipherService implements CipherServiceAbstraction {
return ciphers[cipherId];
}
async deleteAttachmentWithServer(id: string, attachmentId: string): Promise<CipherData> {
async deleteAttachmentWithServer(
id: string,
attachmentId: string,
userId: UserId,
): Promise<CipherData> {
let cipherResponse = null;
try {
cipherResponse = await this.apiService.deleteCipherAttachment(id, attachmentId);
@@ -1119,7 +1124,7 @@ export class CipherService implements CipherServiceAbstraction {
}
const cipherData = CipherData.fromJSON(cipherResponse?.cipher);
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId);
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
}
sortCiphersByLastUsed(a: CipherView, b: CipherView): number {
@@ -1192,8 +1197,8 @@ export class CipherService implements CipherServiceAbstraction {
};
}
async softDelete(id: string | string[]): Promise<any> {
let ciphers = await firstValueFrom(this.ciphers$);
async softDelete(id: string | string[], userId: UserId): Promise<any> {
let ciphers = await firstValueFrom(this.ciphers$(userId));
if (ciphers == null) {
return;
}
@@ -1212,7 +1217,7 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.encryptedCiphersState.update(() => {
await this.encryptedCiphersState(userId).update(() => {
if (ciphers == null) {
ciphers = {};
}
@@ -1220,17 +1225,17 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async softDeleteWithServer(id: string, asAdmin = false): Promise<any> {
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
if (asAdmin) {
await this.apiService.putDeleteCipherAdmin(id);
} else {
await this.apiService.putDeleteCipher(id);
}
await this.softDelete(id);
await this.softDelete(id, userId);
}
async softDeleteManyWithServer(ids: string[], asAdmin = false): Promise<any> {
async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
const request = new CipherBulkDeleteRequest(ids);
if (asAdmin) {
await this.apiService.putDeleteManyCiphersAdmin(request);
@@ -1238,13 +1243,14 @@ export class CipherService implements CipherServiceAbstraction {
await this.apiService.putDeleteManyCiphers(request);
}
await this.softDelete(ids);
await this.softDelete(ids, userId);
}
async restore(
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
userId: UserId,
) {
let ciphers = await firstValueFrom(this.ciphers$);
let ciphers = await firstValueFrom(this.ciphers$(userId));
if (ciphers == null) {
return;
}
@@ -1265,7 +1271,7 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.encryptedCiphersState.update(() => {
await this.encryptedCiphersState(userId).update(() => {
if (ciphers == null) {
ciphers = {};
}
@@ -1273,7 +1279,7 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async restoreWithServer(id: string, asAdmin = false): Promise<any> {
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
let response;
if (asAdmin) {
response = await this.apiService.putRestoreCipherAdmin(id);
@@ -1281,14 +1287,14 @@ export class CipherService implements CipherServiceAbstraction {
response = await this.apiService.putRestoreCipher(id);
}
await this.restore({ id: id, revisionDate: response.revisionDate });
await this.restore({ id: id, revisionDate: response.revisionDate }, userId);
}
/**
* No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable
* The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
*/
async restoreManyWithServer(ids: string[], orgId: string = null): Promise<void> {
async restoreManyWithServer(ids: string[], userId: UserId, orgId: string = null): Promise<void> {
let response;
if (orgId) {
@@ -1303,7 +1309,7 @@ export class CipherService implements CipherServiceAbstraction {
for (const cipher of response.data) {
restores.push({ id: cipher.id, revisionDate: cipher.revisionDate });
}
await this.restore(restores);
await this.restore(restores, userId);
}
async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<UserKey | OrgKey> {
@@ -1313,8 +1319,8 @@ export class CipherService implements CipherServiceAbstraction {
);
}
async setAddEditCipherInfo(value: AddEditCipherInfo) {
await this.addEditCipherInfoState.update(() => value, {
async setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId) {
await this.addEditCipherInfoState(userId).update(() => value, {
shouldUpdate: (current) => !(current == null && value == null),
});
}
@@ -1333,8 +1339,8 @@ export class CipherService implements CipherServiceAbstraction {
let encryptedCiphers: CipherWithIdRequest[] = [];
const ciphers = await firstValueFrom(this.cipherViews$);
const failedCiphers = await firstValueFrom(this.failedToDecryptCiphers$);
const ciphers = await firstValueFrom(this.cipherViews$(userId));
const failedCiphers = await firstValueFrom(this.failedToDecryptCiphers$(userId));
if (!ciphers) {
return encryptedCiphers;
}
@@ -1357,6 +1363,41 @@ export class CipherService implements CipherServiceAbstraction {
return encryptedCiphers;
}
/**
* @returns a SingleUserState
*/
private localDataState(userId: UserId) {
return this.stateProvider.getUser(userId, LOCAL_DATA_KEY);
}
/**
* @returns a SingleUserState for the encrypted ciphers
*/
private encryptedCiphersState(userId: UserId) {
return this.stateProvider.getUser(userId, ENCRYPTED_CIPHERS);
}
/**
* @returns a SingleUserState for the decrypted ciphers
*/
private decryptedCiphersState(userId: UserId) {
return this.stateProvider.getUser(userId, DECRYPTED_CIPHERS);
}
/**
* @returns a SingleUserState for the add/edit cipher info
*/
private addEditCipherInfoState(userId: UserId) {
return this.stateProvider.getUser(userId, ADD_EDIT_CIPHER_INFO_KEY);
}
/**
* @returns a SingleUserState for the failed to decrypt ciphers
*/
private failedToDecryptCiphersState(userId: UserId) {
return this.stateProvider.getUser(userId, FAILED_DECRYPTED_CIPHERS);
}
// Helpers
// In the case of a cipher that is being shared with an organization, we want to decrypt the
@@ -1660,6 +1701,7 @@ export class CipherService implements CipherServiceAbstraction {
private async getCipherForUrl(
url: string,
userId: UserId,
lastUsed: boolean,
lastLaunched: boolean,
autofillOnPageLoad: boolean,
@@ -1667,7 +1709,7 @@ export class CipherService implements CipherServiceAbstraction {
const cacheKey = autofillOnPageLoad ? "autofillOnPageLoad-" + url : url;
if (!this.sortedCiphersCache.isCached(cacheKey)) {
let ciphers = await this.getAllDecryptedForUrl(url);
let ciphers = await this.getAllDecryptedForUrl(url, userId);
if (!ciphers) {
return null;
}

View File

@@ -190,7 +190,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
});
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.cipherService.getAll();
const ciphers = await this.cipherService.getAll(userId);
if (ciphers != null) {
const updates: Cipher[] = [];
for (const cId in ciphers) {

View File

@@ -4,10 +4,8 @@ import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { delay, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { FormFieldModule } from "../form-field";

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./avatar.stories";
@@ -18,15 +18,15 @@ visual indicator to recognize which of a personal or work account a user is logg
### Large: 64px
<Story of={stories.Large} />
<Canvas of={stories.Large} />
### Default: 48px
<Story of={stories.Default} />
<Canvas of={stories.Default} />
### Small 28px
<Story of={stories.Small} />
<Canvas of={stories.Small} />
## Background color
@@ -37,9 +37,9 @@ priority:
- ID
- Text, usually set to the user's Name field
<Story of={stories.ColorByText} />
<Canvas of={stories.ColorByText} />
Use the user 'ID' field if `Name` is not defined.
<Story of={stories.ColorByID} />
<Canvas of={stories.ColorByID} />
## Outline
@@ -47,7 +47,7 @@ If the avatar is displayed on one of the theme's `background` color variables or
display the avatar with a 1 pixel `secondary-600` border to meet WCAG AA graphic contrast guidelines
for interactive elements.
<Story of={stories.Border} />
<Canvas of={stories.Border} />
## Avatar as a button

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./badge.stories";
@@ -31,39 +31,39 @@ The story below uses the `<button>` element to demonstrate all the possible stat
The primary badge is used to indicate an active status (example: device management page) or provide
additional information (example: type of emergency access granted).
<Story of={stories.Primary} />
<Canvas of={stories.Primary} />
### Secondary
The secondary badge style is typically is a default badge style. It is often used to indicate
neutral information.
<Story of={stories.Secondary} />
<Canvas of={stories.Secondary} />
### Success
The success badge is used to indicate a positive status, OR to indicate a feature requires a Premium
subscription. See [Premium Badge](?path=/docs/web-premium-badge--docs)
<Story of={stories.Success} />
<Canvas of={stories.Success} />
### Danger
The danger badge is used to indicate a negative status.
<Story of={stories.Danger} />
<Canvas of={stories.Danger} />
### Warning
The warning badge is used to indicate a status waiting on an additional action from the active user.
<Story of={stories.Warning} />
<Canvas of={stories.Warning} />
### Info
The info badge is used to indicate a low emphasis informative information.
<Story of={stories.Info} />
<Canvas of={stories.Info} />
## Badges as counters

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Controls, Canvas, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Canvas, Primary } from "@storybook/addon-docs";
import * as stories from "./banner.stories";
@@ -31,25 +31,25 @@ Use the following guidelines to help choose the correct type of banner.
### Premium
<Story of={stories.Premium} />
<Canvas of={stories.Premium} />
Used primarily to encourage user to upgrade to premium.
### Info
<Story of={stories.Info} />
<Canvas of={stories.Info} />
Used to communicate release notes, server maintenance or other informative event.
### Warning
<Story of={stories.Warning} />
<Canvas of={stories.Warning} />
Used to alert the user of outdated info or versions.
### Danger
<Story of={stories.Danger} />
<Canvas of={stories.Danger} />
Rarely used, but may be used to alert users over critical messages or very outdated versions.

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./breadcrumbs.stories";
@@ -23,7 +23,7 @@ See [Header with Breadcrumbs](?path=/story/web-header--with-breadcrumbs).
When a user is 1 level deep into a tree, the top level is displayed as a single link above the page
title.
<Story of={stories.TopLevel} />
<Canvas of={stories.TopLevel} />
### Second Level
@@ -31,7 +31,7 @@ When a user is 2 or more levels deep into a tree, the top level is displayed fol
<i class="bwi bwi-angle-right"></i> icon, and the following pages.
<Story of={stories.SecondLevel} />
<Canvas of={stories.SecondLevel} />
### Overflow
@@ -42,7 +42,7 @@ When a user is several levels deep into a tree, the top level or 2 are displayed
When the user selects the <i class="bwi bwi-ellipsis-h"></i> icon button, a menu opens displaying
the pages between the top level and the previous page.
<Story of={stories.Overflow} />
<Canvas of={stories.Overflow} />
### Small screens

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./button.stories";
@@ -43,14 +43,14 @@ There are 3 main styles for the button: Primary, Secondary, and Danger.
### Primary
<Story of={stories.Primary} />
<Canvas of={stories.Primary} />
Use the primary button styling for all Primary call to actions. An action is "primary" if it relates
to the main purpose of a page. There should never be 2 primary styled buttons next to each other.
### Secondary
<Story of={stories.Secondary} />
<Canvas of={stories.Secondary} />
The secondary styling should be used for secondary calls to action. An action is "secondary" if it
relates indirectly to the purpose of a page. There may be multiple secondary buttons next to each
@@ -58,20 +58,20 @@ other; however, generally there should only be 1 or 2 calls to action per page.
### Danger
<Story of={stories.Danger} />
<Canvas of={stories.Danger} />
Use the danger styling only in settings when the user may preform a permanent action.
## Disabled UI
<Story of={stories.Disabled} />
<Canvas of={stories.Disabled} />
## Block
Typically button widths expand with their text. In some causes though buttons may need to be block
where the width is fixed and the text wraps to 2 lines if exceeding the buttons width.
<Story of={stories.Block} />
<Canvas of={stories.Block} />
## Accessibility
@@ -96,7 +96,7 @@ Both submit and async action buttons use a loading button state while an action
button is preforming a long running task in the background like a server API call, be sure to review
the [Async Actions Directive](?path=/story/component-library-async-actions-overview--page).
<Story of={stories.Loading} />
<Canvas of={stories.Loading} />
### appA11yTitle

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./callout.stories";
@@ -28,7 +28,7 @@ Use the success callout to communicate a positive messaging to the user.
The success callout may also be used for the information related to a premium membership. In this
case, replace the icon with <i class="bwi bwi-star" title="bwi-star" aria-label="bwi-star"></i>
<Story of={stories.Success} />
<Canvas of={stories.Success} />
### Info
@@ -39,7 +39,7 @@ information.
**Example:** in the Domain Claiming modal, an info callout is used to tell the user the domain will
automatically be checked.
<Story of={stories.Info} />
<Canvas of={stories.Info} />
### Warning
@@ -49,7 +49,7 @@ irreversible results.
**Example:** the warning callout is used before the change master password and encryption key form
to alert the user that they will be logged out.
<Story of={stories.Warning} />
<Canvas of={stories.Warning} />
### Danger
@@ -59,7 +59,7 @@ not reversible.
The danger callout can also be used to alert the user of an error or errors, such as a server side
errors after form submit or failed communication request.
<Story of={stories.Danger} />
<Canvas of={stories.Danger} />
## Accessibility

View File

@@ -9,9 +9,7 @@ import {
} from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeModule } from "../badge";
import { FormControlModule } from "../form-control";

View File

@@ -18,9 +18,8 @@ import {
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { compareValues } from "../../../common/src/platform/misc/compare-values";
import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { MenuComponent, MenuItemDirective, MenuModule } from "../menu";
@@ -46,7 +45,6 @@ export type ChipSelectOption<T> = Option<T> & {
multi: true,
},
],
preserveWhitespaces: false,
})
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, AfterViewInit {
@ViewChild(MenuComponent) menu: MenuComponent;

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas } from "@storybook/addon-docs";
import * as stories from "./chip-select.stories";
@@ -12,9 +12,7 @@ import { ChipSelectComponent } from "@bitwarden/components";
`<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
<Canvas>
<Story of={stories.Default} />
</Canvas>
<Canvas of={stories.Default} />
## Options
@@ -91,9 +89,7 @@ const options = [
];
```
<Canvas>
<Story of={stories.NestedOptions} />
</Canvas>
<Canvas of={stories.NestedOptions} />
## Placeholder Content

View File

@@ -22,7 +22,6 @@ enum CharacterType {
}
</span>
}`,
preserveWhitespaces: false,
standalone: true,
})
export class ColorPasswordComponent {

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./color-password.stories";
@@ -18,15 +18,15 @@ the logic for displaying letters as `text-main`, numbers as `primary`, and speci
The password count option is used in the Login type form. It is used to highlight each character's
position in the password string.
<Story of={stories.ColorPasswordCount} />
<Canvas of={stories.ColorPasswordCount} />
## Wrapped Password
When the password length is longer than the container's width, it should wrap as shown below.
<Story of={stories.WrappedColorPassword} />
<Canvas of={stories.WrappedColorPassword} />
<Story of={stories.WrappedColorPasswordCount} />
<Canvas of={stories.WrappedColorPasswordCount} />
## Accessibility

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./dialog.stories";
@@ -35,7 +35,7 @@ Use the large size for dialogs that have many interactive elements or tabbed con
`max-w-3xl` 48rem
<Story of={stories.Large} />
<Canvas of={stories.Large} />
### Default
@@ -46,7 +46,7 @@ Use the Default size for most dialogs that require some content and a few intera
`max-w-xl` 36rem
<Story of={stories.Default} />
<Canvas of={stories.Default} />
### Small
@@ -54,31 +54,31 @@ Use the Default size for most dialogs that require some content and a few intera
`max-w-sm` 24rem
<Story of={stories.Small} />
<Canvas of={stories.Small} />
## Long Title
If a dialog's title is too long to fully display. It should be truncated and on hover shown in a
tooltip.
<Story of={stories.LongTitle} />
<Canvas of={stories.LongTitle} />
## Loading
Similar to a page loading state, a Dialog that takes more than a few seconds to load should use a
loading state.
<Story of={stories.Loading} />
<Canvas of={stories.Loading} />
## Tab Content
Use tabs to separate related content within a dialog.
<Story of={stories.TabContent} />
<Canvas of={stories.TabContent} />
## Background Color
The `background` input can be set to `alt` to change the background color. This is useful for
dialogs that contain multiple card sections.
<Story of={stories.WithCards} />
<Canvas of={stories.WithCards} />

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Source } from "@storybook/addon-docs";
import { Meta, Canvas, Source } from "@storybook/addon-docs";
import * as stories from "./dialog.service.stories";
@@ -30,7 +30,7 @@ dialog should become scrollable.
A backdrop should be used to hide the content below the dialog. Use `#000000` with `30% opacity`.
<Story of={stories.Default} />
<Canvas of={stories.Default} />
## Accessibility

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./simple-dialog.stories";
@@ -48,4 +48,4 @@ the simple dialog's type is specified.
Simple dialogs can support scrolling content if necessary, but typically with larger quantities of
content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs).
<Story of={stories.ScrollingContent} />
<Canvas of={stories.ScrollingContent} />

View File

@@ -46,7 +46,7 @@ export type SimpleDialogOptions = {
* If null is provided, the cancel button will be removed.
*
* If not localized, pass in a `Translation` */
cancelButtonText?: string | Translation;
cancelButtonText?: string | Translation | null;
/** Whether or not the user can use escape or clicking the backdrop to close the dialog */
disableClose?: boolean;

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./disclosure.stories";
@@ -34,7 +34,7 @@ To compose a disclosure and trigger:
<bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
```
<Story of={stories.DisclosureWithIconButton} />
<Canvas of={stories.DisclosureWithIconButton} />
<br />
<br />

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./drawer.stories";
@@ -83,13 +83,13 @@ directive:
Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening
another will close and replace the one already open.
<Story of={stories.MultipleDrawers} />
<Canvas of={stories.MultipleDrawers} />
## Headless
Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content.
<Story of={stories.Headless} />
<Canvas of={stories.Headless} />
## Accessibility
@@ -117,4 +117,4 @@ Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable
## Kitchen Sink
<Story of={KitchenSink} autoplay />
<Canvas of={KitchenSink} autoplay />

View File

@@ -9,10 +9,7 @@ import { ButtonModule } from "../button";
import { CalloutModule } from "../callout";
import { LayoutComponent } from "../layout";
import { mockLayoutI18n } from "../layout/mocks";
import {
disableBothThemeDecorator,
positionFixedWrapperDecorator,
} from "../stories/storybook-decorators";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils";
@@ -30,7 +27,6 @@ export default {
},
decorators: [
positionFixedWrapperDecorator(),
disableBothThemeDecorator,
moduleMetadata({
imports: [
RouterTestingModule,

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Source } from "@storybook/addon-docs";
import { Meta, Canvas, Source } from "@storybook/addon-docs";
import * as formStories from "./form.stories";
import * as fieldStories from "../form-field/form-field.stories";
@@ -17,7 +17,7 @@ Component Library forms should always be built using [Angular Reactive Forms][re
[ADR-0001][adr-0001] for a background to this decision. In practice this means that forms should
always use the native `form` element and bind a `formGroup`.
<Story of={formStories.FullExample} />
<Canvas of={formStories.FullExample} />
<br />
@@ -69,25 +69,25 @@ is too long, such as info link buttons or badges.
#### Default with required attribute
<Story of={fieldStories.Default} />
<Canvas of={fieldStories.Default} />
#### Password Toggle
<Story of={passwordToggleStories.Default} />
<Canvas of={passwordToggleStories.Default} />
### Search
<Story of={searchStories.Default} />
<Canvas of={searchStories.Default} />
### Selects
#### Searchable single select (default)
<Story of={selectStories.Default} />
<Canvas of={selectStories.Default} />
#### Multi-select
<Story of={multiSelectStories.Members} />
<Canvas of={multiSelectStories.Members} />
### Radio group
@@ -113,11 +113,11 @@ using a radio group for more than 5 options even if the options require addition
#### Block
<Story of={radioStories.Block} />
<Canvas of={radioStories.Block} />
#### Inline
<Story of={radioStories.Inline} />
<Canvas of={radioStories.Inline} />
### Checkbox
@@ -140,7 +140,7 @@ If a checkbox group has more than 4 options a
#### Single checkbox
<Story of={checkboxStories.Default} />
<Canvas of={checkboxStories.Default} />
## Accessibility

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./icon-button.stories";
@@ -38,48 +38,48 @@ button component styles.
Used for general icon buttons appearing on the themes main `background`
<Story of={stories.Main} />
<Canvas of={stories.Main} />
### Muted
Used for low emphasis icon buttons appearing on the themes main `background`
<Story of={stories.Muted} />
<Canvas of={stories.Muted} />
### Contrast
Used on a themes colored or contrasting backgrounds such as in the navigation or on toasts and
banners.
<Story of={stories.Contrast} />
<Canvas of={stories.Contrast} />
### Danger
Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of
the dialog component.
<Story of={stories.Danger} />
<Canvas of={stories.Danger} />
### Primary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story of={stories.Primary} />
<Canvas of={stories.Primary} />
### Secondary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story of={stories.Secondary} />
<Canvas of={stories.Secondary} />
### Light
Used on a background that is dark in both light theme and dark theme. Example: end user navigation
styles.
<Story of={stories.Light} />
<Canvas of={stories.Light} />
**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus
indicator does not meet WCAG graphic contrast guidelines.
@@ -93,11 +93,11 @@ with less padding around the icon, such as in the navigation component.
### Small
<Story of={stories.Small} />
<Canvas of={stories.Small} />
### Default
<Story of={stories.Default} />
<Canvas of={stories.Default} />
## Accessibility

View File

@@ -1,4 +1,4 @@
export { ButtonType } from "./shared/button-like.abstraction";
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
export * from "./a11y";
export * from "./async-actions";
export * from "./avatar";

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./item.stories";
@@ -15,14 +15,14 @@ import { ItemModule } from "@bitwarden/components";
It is a generic container that can be used for either standalone content, an alternative to tables,
or to list nav links.
<Story of={stories.Default} />
<Canvas of={stories.Default} />
<br />
Items used within a parent `bit-layout` component will not have a border radius, since the
`bit-layout` background is white.
<Story of={stories.WithoutBorderRadius} />
<Canvas of={stories.WithoutBorderRadius} />
<br />
<br />
@@ -49,7 +49,7 @@ The content can be a button, anchor, or static container.
</bit-item>
```
<Story of={stories.ContentTypes} />
<Canvas of={stories.ContentTypes} />
### Content Slots
@@ -82,7 +82,7 @@ The content can be a button, anchor, or static container.
</bit-item>
```
<Story of={stories.ContentSlots} />
<Canvas of={stories.ContentSlots} />
## Secondary Actions
@@ -129,19 +129,19 @@ This can be changed by passing `[truncate]="false"` to the `bit-item-content`.
### Truncation (Default)
<Story of={stories.TextOverflowTruncate} />
<Canvas of={stories.TextOverflowTruncate} />
### Wrap
<Story of={stories.TextOverflowWrap} />
<Canvas of={stories.TextOverflowWrap} />
## Item Groups
Groups of items can be associated by wrapping them in the `<bit-item-group>`.
<Story of={stories.MultipleActionList} />
<Canvas of={stories.MultipleActionList} />
<Story of={stories.SingleActionList} />
<Canvas of={stories.SingleActionList} />
### A11y
@@ -162,4 +162,4 @@ Use `aria-label` or `aria-labelledby` to give groups an accessible name.
### Virtual Scrolling
<Story of={stories.VirtualScrolling} />
<Canvas of={stories.VirtualScrolling} />

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./menu.stories";
@@ -9,7 +9,7 @@ import * as stories from "./menu.stories";
Menus are used to help organize related options. Menus are most often used for item options in
tables.
<Story of={stories.ClosedMenu} />
<Canvas of={stories.ClosedMenu} />
<br />

View File

@@ -29,7 +29,6 @@ import { SideNavService } from "./side-nav.service";
],
standalone: true,
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
preserveWhitespaces: false,
})
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
@ContentChildren(NavBaseComponent, {

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./popover.stories";
@@ -66,7 +66,7 @@ not work because there is not enough space to open the Popover to the right. The
The first position that "fits" is `left-start`, and therefore that is where the Popover will open.
<Story of={stories.LeftStart} />
<Canvas of={stories.LeftStart} />
### Manually Setting a Position
@@ -77,7 +77,7 @@ Popover's trigger element, such as:
<button [bitPopoverTriggerFor]="myPopover" [position]="'above-end'">Open Popover</button>
```
<Story of={stories.AboveEnd} />
<Canvas of={stories.AboveEnd} />
Note that if the user resizes the page and the Popover no longer fits in the viewport, the Popover
component will fall back to the list of default positions to find the best position.

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./progress.stories";
@@ -19,14 +19,14 @@ allows those who may not be familiar with the pattern to be able to read and dig
It also allows assistive technology to accurately describe the indicator to those who may be unable
to see part or all of the indicator.
<Story of={stories.Full} />
<Canvas of={stories.Full} />
## Text label
When measuring something other than progress, such as password strength, update the label to fit the
context of the implementation.
<Story of={stories.CustomText} />
<Canvas of={stories.CustomText} />
### Strength indicator styles

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas } from "@storybook/addon-docs";
import * as stories from "./section.stories";
@@ -26,9 +26,7 @@ the `disableMargin` input.
<bit-section disableMargin></bit-section>
```
<Canvas>
<Story of={stories.Default} />
</Canvas>
<Canvas of={stories.Default} />
## Section Header
@@ -61,16 +59,12 @@ padding to align the header with the border radius of the card/item.
</bit-section>
```
<Canvas>
<Story of={stories.HeaderWithPadding} />
</Canvas>
<Canvas of={stories.HeaderWithPadding} />
If placed inside of a section without a `bit-card` or `bit-item`, or with a `bit-card`/`bit-item`
that is not a descendant of the immediate next sibling, the padding is not applied.
<Canvas>
<Story of={stories.HeaderWithoutPadding} />
</Canvas>
<Canvas of={stories.HeaderWithoutPadding} />
### Section Header Content Slots
@@ -89,14 +83,10 @@ Anything passed to the default slot will display as part of the title. The title
Title suffixes (typically an icon or icon button) can be added as well. A gap is automatically
applied between the children of the default slot.
<Canvas>
<Story of={stories.HeaderVariants} />
</Canvas>
<Canvas of={stories.HeaderVariants} />
#### End slot
The `end` slot will typically be used for text or an icon button.
<Canvas>
<Story of={stories.HeaderEndSlotVariants} />
</Canvas>
<Canvas of={stories.HeaderEndSlotVariants} />

View File

@@ -13,7 +13,7 @@ export const Table = (args) => (
<table class={"border tw-table-auto !tw-text-main " + args.class}>
<thead>
<tr>
<th>General usage</th>
<th class="tw-w-40">General usage</th>
<th class="tw-w-20"></th>
</tr>
</thead>
@@ -119,6 +119,4 @@ Below are all the permited colors. Please consult design before considering addi
<div class="tw-flex tw-space-x-4">
<Table />
<Table class="theme_dark tw-bg-background" />
<Table class="theme_nord tw-bg-background" />
<Table class="theme_solarize tw-bg-background" />
</div>

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from "@storybook/addon-docs";
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import * as itemStories from "../item/item.stories";
import * as popupLayoutStories from "../../../../apps/browser/src/platform/popup/layout/popup-layout.stories";
@@ -42,4 +42,4 @@ However, styling with the Tailwind variant should be used when possible as it is
### [Item](?path=/story/component-library-item--compact-mode)
<Story of={itemStories.CompactMode} />
<Canvas of={itemStories.CompactMode} />

View File

@@ -56,12 +56,6 @@ class KitchenSinkDialog {
isolated stories. The stories for the Kitchen Sink exist to be tested by the Chromatic UI
tests.
</p>
<p bitTypography="body1">
NOTE: These stories will treat "Light & Dark" mode as "Light" mode. This is done to avoid a
bug with the way that we render the same component twice in the same iframe and how that
interacts with the <code>router-outlet</code>.
</p>
</bit-callout>
<bit-tab-group label="Main content tabs" class="tw-text-main">

View File

@@ -9,7 +9,3 @@ import * as stories from "./kitchen-sink.stories";
The purpose of this story is to compose together all of our components. When snapshot tests run,
we'll be able to spot-check visual changes in a more app-like environment than just the isolated
stories. The stories for the Kitchen Sink exist to be tested by the Chromatic UI tests.
NOTE: These stories will treat "Light & Dark" mode as "Light" mode. This is done to avoid a bug with
the way that we render the same component twice in the same iframe and how that interacts with the
`router-outlet`.

View File

@@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { DialogService } from "../../dialog";
import { LayoutComponent } from "../../layout";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { disableBothThemeDecorator, positionFixedWrapperDecorator } from "../storybook-decorators";
import { positionFixedWrapperDecorator } from "../storybook-decorators";
import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component";
import { KitchenSinkForm } from "./components/kitchen-sink-form.component";
@@ -31,7 +31,6 @@ export default {
component: LayoutComponent,
decorators: [
positionFixedWrapperDecorator(),
disableBothThemeDecorator,
moduleMetadata({
imports: [
KitchenSinkSharedModule,

View File

@@ -12,20 +12,8 @@ export const positionFixedWrapperDecorator = (wrapper?: (story: string) => strin
*/
(story) =>
/* HTML */ `<div
class="tw-scale-100 tw-h-screen tw-border-2 tw-border-solid tw-border-secondary-300 tw-overflow-auto"
class="tw-scale-100 tw-h-screen tw-border-2 tw-border-solid tw-border-secondary-300 tw-overflow-auto tw-box-content"
>
${wrapper ? wrapper(story) : story}
</div>`,
);
export const disableBothThemeDecorator = componentWrapperDecorator(
(story) => story,
({ globals }) => {
/**
* avoid a bug with the way that we render the same component twice in the same iframe and how
* that interacts with the router-outlet
*/
const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"];
return { theme: themeOverride };
},
);

Some files were not shown because too many files have changed in this diff Show More