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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum TwoFactorProviderType {
|
||||
Remember = 5,
|
||||
OrganizationDuo = 6,
|
||||
WebAuthn = 7,
|
||||
RecoveryCode = 8,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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$);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
8
libs/common/src/vault/enums/vault-messages.enum.ts
Normal file
8
libs/common/src/vault/enums/vault-messages.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const VaultMessages = {
|
||||
HasBwInstalled: "hasBwInstalled",
|
||||
checkBwInstalled: "checkIfBWExtensionInstalled",
|
||||
OpenPopup: "openPopup",
|
||||
PopupOpened: "popupOpened",
|
||||
} as const;
|
||||
|
||||
export { VaultMessages };
|
||||
@@ -1,6 +0,0 @@
|
||||
const VaultOnboardingMessages = {
|
||||
HasBwInstalled: "hasBwInstalled",
|
||||
checkBwInstalled: "checkIfBWExtensionInstalled",
|
||||
} as const;
|
||||
|
||||
export { VaultOnboardingMessages };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 button’s 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ enum CharacterType {
|
||||
}
|
||||
</span>
|
||||
}`,
|
||||
preserveWhitespaces: false,
|
||||
standalone: true,
|
||||
})
|
||||
export class ColorPasswordComponent {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 theme’s main `background`
|
||||
|
||||
<Story of={stories.Main} />
|
||||
<Canvas of={stories.Main} />
|
||||
|
||||
### Muted
|
||||
|
||||
Used for low emphasis icon buttons appearing on the theme’s main `background`
|
||||
|
||||
<Story of={stories.Muted} />
|
||||
<Canvas of={stories.Muted} />
|
||||
|
||||
### Contrast
|
||||
|
||||
Used on a theme’s 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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user