Merge branch 'main' of https://github.com/bitwarden/clients into PM-1943-migrate-recover-delete-component

This commit is contained in:
KiruthigaManivannan 2024-05-15 16:15:11 +05:30
commit 72ba3d9b85
65 changed files with 854 additions and 289 deletions

View File

@ -1,7 +1,7 @@
import { Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
import { Subject, firstValueFrom, map, of, switchMap, takeUntil } from "rxjs";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -49,7 +49,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
readonly currentAccount$ = this.accountService.activeAccount$.pipe(
switchMap((a) =>
a == null
? null
? of(null)
: this.authService.activeAccountStatus$.pipe(map((s) => ({ ...a, status: s }))),
),
);
@ -106,12 +106,14 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
});
if (confirmed) {
this.messagingService.send("logout", { userId });
const result = await this.accountSwitcherService.logoutAccount(userId);
// unlocked logout responses need to be navigated out of the account switcher.
// other responses will be handled by background and app.component
if (result?.status === AuthenticationStatus.Unlocked) {
this.location.back();
}
}
// 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(["home"]);
this.loading = false;
}
ngOnDestroy() {

View File

@ -1,10 +1,10 @@
import { CommonModule, Location } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AvatarModule } from "@bitwarden/components";
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
@ -21,9 +21,9 @@ export class AccountComponent {
constructor(
private accountSwitcherService: AccountSwitcherService,
private router: Router,
private location: Location,
private i18nService: I18nService,
private logService: LogService,
) {}
get specialAccountAddId() {
@ -32,15 +32,19 @@ export class AccountComponent {
async selectAccount(id: string) {
this.loading.emit(true);
await this.accountSwitcherService.selectAccount(id);
let result;
try {
result = await this.accountSwitcherService.selectAccount(id);
} catch (e) {
this.logService.error("Error selecting account", e);
}
if (id === this.specialAccountAddId) {
// 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(["home"]);
} else {
// Navigate out of account switching for unlocked accounts
// locked or logged out account statuses are handled by background and app.component
if (result?.status === AuthenticationStatus.Unlocked) {
this.location.back();
}
this.loading.emit(false);
}
get status() {

View File

@ -186,4 +186,35 @@ describe("AccountSwitcherService", () => {
expect(removeListenerSpy).toBeCalledTimes(1);
});
});
describe("logout", () => {
const userId1 = "1" as UserId;
const userId2 = "2" as UserId;
it("initiates logout", async () => {
let listener: (
message: { command: string; userId: UserId; status: AuthenticationStatus },
sender: unknown,
sendResponse: unknown,
) => void;
jest.spyOn(chrome.runtime.onMessage, "addListener").mockImplementation((addedListener) => {
listener = addedListener;
});
const removeListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener");
const logoutPromise = accountSwitcherService.logoutAccount(userId1);
listener(
{ command: "switchAccountFinish", userId: userId2, status: AuthenticationStatus.Unlocked },
undefined,
undefined,
);
const result = await logoutPromise;
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId: userId1 });
expect(result).toEqual({ newUserId: userId2, status: AuthenticationStatus.Unlocked });
expect(removeListenerSpy).toBeCalledTimes(1);
});
});
});

View File

@ -41,7 +41,7 @@ export class AccountSwitcherService {
SPECIAL_ADD_ACCOUNT_ID = "addAccount";
availableAccounts$: Observable<AvailableAccount[]>;
switchAccountFinished$: Observable<string>;
switchAccountFinished$: Observable<{ userId: UserId; status: AuthenticationStatus }>;
constructor(
private accountService: AccountService,
@ -111,11 +111,11 @@ export class AccountSwitcherService {
);
// Create a reusable observable that listens to the switchAccountFinish message and returns the userId from the message
this.switchAccountFinished$ = fromChromeEvent<[message: { command: string; userId: string }]>(
chrome.runtime.onMessage,
).pipe(
this.switchAccountFinished$ = fromChromeEvent<
[message: { command: string; userId: UserId; status: AuthenticationStatus }]
>(chrome.runtime.onMessage).pipe(
filter(([message]) => message.command === "switchAccountFinish"),
map(([message]) => message.userId),
map(([message]) => ({ userId: message.userId, status: message.status })),
);
}
@ -127,12 +127,46 @@ export class AccountSwitcherService {
if (id === this.SPECIAL_ADD_ACCOUNT_ID) {
id = null;
}
const userId = id as UserId;
// Creates a subscription to the switchAccountFinished observable but further
// filters it to only care about the current userId.
const switchAccountFinishedPromise = firstValueFrom(
const switchAccountFinishedPromise = this.listenForSwitchAccountFinish(userId);
// Initiate the actions required to make account switching happen
await this.accountService.switchAccount(userId);
this.messagingService.send("switchAccount", { userId }); // This message should cause switchAccountFinish to be sent
// Wait until we receive the switchAccountFinished message
return await switchAccountFinishedPromise;
}
/**
*
* @param userId the user id to logout
* @returns the userId and status of the that has been switch to due to the logout. null on errors.
*/
async logoutAccount(
userId: UserId,
): Promise<{ newUserId: UserId; status: AuthenticationStatus } | null> {
// logout creates an account switch to the next up user, which may be null
const switchPromise = this.listenForSwitchAccountFinish(null);
await this.messagingService.send("logout", { userId });
// wait for account switch to happen, the result will be the new user id and status
const result = await switchPromise;
return { newUserId: result.userId, status: result.status };
}
// Listens for the switchAccountFinish message and returns the userId from the message
// Optionally filters switchAccountFinish to an expected userId
private listenForSwitchAccountFinish(
expectedUserId: UserId | null,
): Promise<{ userId: UserId; status: AuthenticationStatus } | null> {
return firstValueFrom(
this.switchAccountFinished$.pipe(
filter((userId) => userId === id),
filter(({ userId }) => (expectedUserId ? userId === expectedUserId : true)),
timeout({
// Much longer than account switching is expected to take for normal accounts
// but the account switching process includes a possible full sync so we need to account
@ -143,20 +177,13 @@ export class AccountSwitcherService {
throwError(() => new Error(AccountSwitcherService.incompleteAccountSwitchError)),
}),
),
);
// Initiate the actions required to make account switching happen
await this.accountService.switchAccount(id as UserId);
this.messagingService.send("switchAccount", { userId: id }); // This message should cause switchAccountFinish to be sent
// Wait until we recieve the switchAccountFinished message
await switchAccountFinishedPromise.catch((err) => {
).catch((err) => {
if (
err instanceof Error &&
err.message === AccountSwitcherService.incompleteAccountSwitchError
) {
this.logService.warning("message 'switchAccount' never responded.");
return;
return null;
}
throw err;
});

View File

@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended";
import {
AssertCredentialResult,
CreateCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
export function createCredentialCreationOptionsMock(
customFields: Partial<CredentialCreationOptions> = {},

View File

@ -78,6 +78,9 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
@ -97,6 +100,7 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
@ -105,6 +109,8 @@ import { DefaultConfigService } from "@bitwarden/common/platform/services/config
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service";
import { Fido2ClientService } from "@bitwarden/common/platform/services/fido2/fido2-client.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
@ -157,9 +163,6 @@ import { UserId } from "@bitwarden/common/types/guid";
import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/vault/abstractions/collection.service";
import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@ -170,8 +173,6 @@ import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwar
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/services/collection.service";
import { Fido2AuthenticatorService } from "@bitwarden/common/vault/services/fido2/fido2-authenticator.service";
import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
@ -815,6 +816,7 @@ export default class MainBackground {
logoutCallback,
this.billingAccountProfileStateService,
this.tokenService,
this.authService,
);
this.eventUploadService = new EventUploadService(
this.apiService,
@ -1180,6 +1182,7 @@ export default class MainBackground {
* Switch accounts to indicated userId -- null is no active user
*/
async switchAccount(userId: UserId) {
let nextAccountStatus: AuthenticationStatus;
try {
const currentlyActiveAccount = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
@ -1187,6 +1190,8 @@ export default class MainBackground {
// can be removed once password generation history is migrated to state providers
await this.stateService.clearDecryptedData(currentlyActiveAccount);
await this.accountService.switchAccount(userId);
// Clear sequentialized caches
clearCaches();
if (userId == null) {
this.loginEmailService.setRememberEmail(false);
@ -1194,11 +1199,12 @@ export default class MainBackground {
await this.refreshBadge();
await this.refreshMenu();
await this.overlayBackground.updateOverlayCiphers();
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts
this.messagingService.send("goHome");
return;
}
const status = await this.authService.getAuthStatus(userId);
nextAccountStatus = await this.authService.getAuthStatus(userId);
const forcePasswordReset =
(await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId))) !=
ForceSetPasswordReason.None;
@ -1206,7 +1212,9 @@ export default class MainBackground {
await this.systemService.clearPendingClipboard();
await this.notificationsService.updateConnection(false);
if (status === AuthenticationStatus.Locked) {
if (nextAccountStatus === AuthenticationStatus.LoggedOut) {
this.messagingService.send("goHome");
} else if (nextAccountStatus === AuthenticationStatus.Locked) {
this.messagingService.send("locked", { userId: userId });
} else if (forcePasswordReset) {
this.messagingService.send("update-temp-password", { userId: userId });
@ -1214,11 +1222,14 @@ export default class MainBackground {
this.messagingService.send("unlocked", { userId: userId });
await this.refreshBadge();
await this.refreshMenu();
await this.overlayBackground.updateOverlayCiphers();
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts
await this.syncService.fullSync(false);
}
} finally {
this.messagingService.send("switchAccountFinish", { userId: userId });
this.messagingService.send("switchAccountFinish", {
userId: userId,
status: nextAccountStatus,
});
}
}
@ -1239,6 +1250,13 @@ export default class MainBackground {
await this.eventUploadService.uploadEvents(userBeingLoggedOut);
const newActiveUser =
userBeingLoggedOut === activeUserId
? await firstValueFrom(this.accountService.nextUpAccount$.pipe(map((a) => a?.id)))
: null;
await this.switchAccount(newActiveUser);
// HACK: We shouldn't wait for the authentication status to change but instead subscribe to the
// authentication status to do various actions.
const logoutPromise = firstValueFrom(
@ -1273,11 +1291,6 @@ export default class MainBackground {
//Needs to be checked before state is cleaned
const needStorageReseed = await this.needsStorageReseed(userId);
const newActiveUser =
userBeingLoggedOut === activeUserId
? await firstValueFrom(this.accountService.nextUpAccount$.pipe(map((a) => a?.id)))
: null;
await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut);
@ -1286,16 +1299,10 @@ export default class MainBackground {
// HACK: Wait for the user logging outs authentication status to transition to LoggedOut
await logoutPromise;
await this.switchAccount(newActiveUser);
if (newActiveUser != null) {
// we have a new active user, do not continue tearing down application
this.messagingService.send("switchAccountFinish");
} else {
this.messagingService.send("doneLoggingOut", {
expired: expired,
userId: userBeingLoggedOut,
});
}
this.messagingService.send("doneLoggingOut", {
expired: expired,
userId: userBeingLoggedOut,
});
if (needStorageReseed) {
await this.reseedStorage();
@ -1308,9 +1315,7 @@ export default class MainBackground {
}
await this.refreshBadge();
await this.mainContextMenuHandler?.noAccess();
// 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.notificationsService.updateConnection(false);
await this.notificationsService.updateConnection(false);
await this.systemService.clearPendingClipboard();
await this.systemService.startProcessReload(this.authService);
}

View File

@ -1,4 +1,4 @@
import { mergeMap } from "rxjs";
import { filter, mergeMap } from "rxjs";
import {
AbstractStorageService,
@ -34,6 +34,11 @@ export default abstract class AbstractChromeStorageService
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {
this.updates$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
filter(([changes]) => {
// Our storage services support changing only one key at a time. If more are changed, it's due to
// reseeding storage and we should ignore the changes.
return Object.keys(changes).length === 1;
}),
mergeMap(([changes]) => {
return Object.entries(changes).map(([key, change]) => {
// The `newValue` property isn't on the StorageChange object

View File

@ -91,13 +91,9 @@ export class AppComponent implements OnInit, OnDestroy {
message: this.i18nService.t("loginExpired"),
});
}
// 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(["home"]);
});
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked") {
} else if (msg.command === "authBlocked" || msg.command === "goHome") {
// 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(["home"]);
@ -137,9 +133,6 @@ export class AppComponent implements OnInit, OnDestroy {
// 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(["/remove-password"]);
} else if (msg.command === "switchAccountFinish") {
// TODO: unset loading?
// this.loading = false;
} else if (msg.command == "update-temp-password") {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@ -3,7 +3,7 @@ import {
AssertCredentialResult,
CreateCredentialParams,
CreateCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
type SharedFido2ScriptInjectionDetails = {
runAt: browser.contentScripts.RegisteredContentScriptOptions["runAt"];

View File

@ -1,13 +1,13 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AssertCredentialParams,
CreateCredentialParams,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Fido2ClientService } from "@bitwarden/common/platform/services/fido2/fido2-client.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
import {

View File

@ -1,14 +1,14 @@
import { firstValueFrom, startWith } from "rxjs";
import { pairwise } from "rxjs/operators";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AssertCredentialParams,
AssertCredentialResult,
CreateCredentialParams,
CreateCredentialResult,
Fido2ClientService,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";

View File

@ -16,14 +16,14 @@ import {
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserRequestedFallbackAbortReason } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { UserRequestedFallbackAbortReason } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import {
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
Fido2UserInterfaceSession,
NewCredentialParams,
PickCredentialParams,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { BrowserApi } from "../../platform/browser/browser-api";
import { closeFido2Popout, openFido2Popout } from "../popup/utils/vault-popout-window";

View File

@ -1,6 +1,6 @@
import { mock, MockProxy } from "jest-mock-extended";
import { CreateCredentialResult } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { CreateCredentialResult } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
import { triggerPortOnDisconnectEvent } from "../../../autofill/spec/testing-utils";

View File

@ -1,7 +1,7 @@
import {
AssertCredentialParams,
CreateCredentialParams,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { sendExtensionMessage } from "../../../autofill/utils";
import { Fido2PortName } from "../enums/fido2-port-name.enum";

View File

@ -3,7 +3,7 @@ import {
CreateCredentialResult,
AssertCredentialParams,
AssertCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
export enum MessageType {
CredentialCreationRequest,

View File

@ -1,4 +1,4 @@
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { FallbackRequestedError } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { Message, MessageType } from "./message";

View File

@ -1,4 +1,4 @@
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { FallbackRequestedError } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { WebauthnUtils } from "../webauthn-utils";

View File

@ -1,8 +1,8 @@
import {
CreateCredentialResult,
AssertCredentialResult,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { Fido2Utils } from "@bitwarden/common/vault/services/fido2/fido2-utils";
} from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import {
InsecureAssertCredentialParams,

View File

@ -664,6 +664,7 @@ export class Main {
async (expired: boolean) => await this.logout(),
this.billingAccountProfileStateService,
this.tokenService,
this.authService,
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);

View File

@ -38,6 +38,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid";
@ -396,6 +397,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.router.navigate(["/remove-password"]);
break;
case "switchAccount": {
// Clear sequentialized caches
clearCaches();
if (message.userId != null) {
await this.stateService.clearDecryptedData(message.userId);
await this.accountService.switchAccount(message.userId);

View File

@ -273,12 +273,13 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
// If the current user is not already in the group and cannot add themselves, remove them from the list
if (restrictGroupAccess) {
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
// organizationUserId may be null if accessing via a provider
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id)?.id;
const isAlreadyInGroup = this.groupForm.value.members.some(
(m) => m.id === organizationUserId,
);
if (!isAlreadyInGroup) {
if (organizationUserId != null && !isAlreadyInGroup) {
this.members = this.members.filter((m) => m.id !== organizationUserId);
}
}

View File

@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
@ -10,7 +10,7 @@ import { Icon } from "@bitwarden/components";
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})
export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
export class AnonLayoutWrapperComponent {
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
@ -23,12 +23,4 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.pageSubtitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageSubtitle"]);
this.pageIcon = this.route.snapshot.firstChild.data["pageIcon"]; // don't translate
}
ngOnInit() {
document.body.classList.add("layout_frontend");
}
ngOnDestroy() {
document.body.classList.remove("layout_frontend");
}
}

View File

@ -3,7 +3,6 @@ import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angula
import { AbstractControl, FormBuilder, Validators } from "@angular/forms";
import {
combineLatest,
from,
map,
Observable,
of,
@ -23,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BitValidators, DialogService } from "@bitwarden/components";
@ -56,7 +54,10 @@ export interface CollectionDialogParams {
initialTab?: CollectionDialogTabType;
parentCollectionId?: string;
showOrgSelector?: boolean;
collectionIds?: string[];
/**
* Flag to limit the nested collections to only those the user has explicit CanManage access too.
*/
limitNestedCollections?: boolean;
readonly?: boolean;
}
@ -85,7 +86,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected tabIndex: CollectionDialogTabType;
protected loading = true;
protected organization?: Organization;
protected collection?: CollectionView;
protected collection?: CollectionAdminView;
protected nestOptions: CollectionView[] = [];
protected accessItems: AccessItemView[] = [];
protected deletedParentName: string | undefined;
@ -107,7 +108,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService,
private groupService: GroupService,
private collectionAdminService: CollectionAdminService,
private collectionService: CollectionService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private organizationUserService: OrganizationUserService,
@ -124,7 +124,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.showOrgSelector = true;
this.formGroup.controls.selectedOrg.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((id) => this.loadOrg(id, this.params.collectionIds));
.subscribe((id) => this.loadOrg(id));
this.organizations$ = this.organizationService.organizations$.pipe(
first(),
map((orgs) =>
@ -138,11 +138,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
} else {
// Opened from the org vault
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
await this.loadOrg(this.params.organizationId, this.params.collectionIds);
await this.loadOrg(this.params.organizationId);
}
}
async loadOrg(orgId: string, collectionIds: string[]) {
async loadOrg(orgId: string) {
const organization$ = this.organizationService
.get$(orgId)
.pipe(shareReplay({ refCount: true, bufferSize: 1 }));
@ -158,28 +158,14 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
combineLatest({
organization: organization$,
collections: this.collectionAdminService.getAll(orgId),
collectionDetails: this.params.collectionId
? from(this.collectionAdminService.get(orgId, this.params.collectionId))
: of(null),
groups: groups$,
// Collection(s) needed to map readonlypermission for (potential) access selector disabled state
users: this.organizationUserService.getAllUsers(orgId, { includeCollections: true }),
collection: this.params.collectionId
? this.collectionService.get(this.params.collectionId)
: of(null),
flexibleCollectionsV1: this.flexibleCollectionsV1Enabled$,
})
.pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$))
.subscribe(
({
organization,
collections,
collectionDetails,
groups,
users,
collection,
flexibleCollectionsV1,
}) => {
({ organization, collections: allCollections, groups, users, flexibleCollectionsV1 }) => {
this.organization = organization;
this.accessItems = [].concat(
groups.map((group) => mapGroupToAccessItemView(group, this.collectionId)),
@ -189,37 +175,48 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
// Force change detection to update the access selector's items
this.changeDetectorRef.detectChanges();
if (collectionIds) {
collections = collections.filter((c) => collectionIds.includes(c.id));
}
this.nestOptions = this.params.limitNestedCollections
? allCollections.filter((c) => c.manage)
: allCollections;
if (this.params.collectionId) {
this.collection = collections.find((c) => c.id === this.collectionId);
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
this.collection = allCollections.find((c) => c.id === this.collectionId);
// Ensure we don't allow nesting the current collection within itself
this.nestOptions = this.nestOptions.filter((c) => c.id !== this.collectionId);
if (!this.collection) {
throw new Error("Could not find collection to edit.");
}
const { name, parent } = parseName(this.collection);
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) {
this.deletedParentName = parent;
// Parse the name to find its parent name
const { name, parent: parentName } = parseName(this.collection);
// Determine if the user can see/select the parent collection
if (parentName !== undefined) {
if (
this.organization.canViewAllCollections &&
!allCollections.find((c) => c.name === parentName)
) {
// The user can view all collections, but the parent was not found -> assume it has been deleted
this.deletedParentName = parentName;
} else if (!this.nestOptions.find((c) => c.name === parentName)) {
// We cannot find the current parent collection in our list of options, so add a placeholder
this.nestOptions.unshift({ name: parentName } as CollectionView);
}
}
const accessSelections = mapToAccessSelections(collectionDetails);
const accessSelections = mapToAccessSelections(this.collection);
this.formGroup.patchValue({
name,
externalId: this.collection.externalId,
parent,
parent: parentName,
access: accessSelections,
});
this.collection.manage = collection?.manage ?? false; // Get manage flag from sync data collection
this.showDeleteButton =
!this.dialogReadonly &&
this.collection.canDelete(organization, flexibleCollectionsV1);
} else {
this.nestOptions = collections;
const parent = collections.find((c) => c.id === this.params.parentCollectionId);
const parent = this.nestOptions.find((c) => c.id === this.params.parentCollectionId);
const currentOrgUserId = users.data.find(
(u) => u.userId === this.organization?.userId,
)?.id;

View File

@ -3,7 +3,7 @@
type="checkbox"
bitCheckbox
appStopProp
*ngIf="canDeleteCollection"
*ngIf="showCheckbox"
[disabled]="disabled"
[checked]="checked"
(change)="$event ? this.checkedToggled.next() : null"

View File

@ -90,4 +90,12 @@ export class VaultCollectionRowComponent {
protected deleteCollection() {
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
}
protected get showCheckbox() {
if (this.flexibleCollectionsV1Enabled) {
return this.collection?.id !== Unassigned;
}
return this.canDeleteCollection;
}
}

View File

@ -245,11 +245,23 @@ export class VaultItemsComponent {
const items: VaultItem[] = [].concat(collections).concat(ciphers);
this.selection.clear();
this.editableItems = items.filter(
(item) =>
item.cipher !== undefined ||
(item.collection !== undefined && this.canDeleteCollection(item.collection)),
);
if (this.flexibleCollectionsV1Enabled) {
// Every item except for the Unassigned collection is selectable, individual bulk actions check the user's permission
this.editableItems = items.filter(
(item) =>
item.cipher !== undefined ||
(item.collection !== undefined && item.collection.id !== Unassigned),
);
} else {
// only collections the user can delete are selectable
this.editableItems = items.filter(
(item) =>
item.cipher !== undefined ||
(item.collection !== undefined && this.canDeleteCollection(item.collection)),
);
}
this.dataSource.data = items;
}

View File

@ -62,13 +62,23 @@ export class CollectionAdminView extends CollectionView {
}
/**
* Whether the current user can edit the collection, including user and group access
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/
override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return org?.flexibleCollections
? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage
: org?.canEditAnyCollection(flexibleCollectionsV1Enabled) ||
(org?.canEditAssignedCollections && this.assigned);
return (
org?.canEditAnyCollection(flexibleCollectionsV1Enabled) ||
super.canEdit(org, flexibleCollectionsV1Enabled)
);
}
/**
* Returns true if the user can delete a collection from the Admin Console.
*/
override canDelete(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return (
org?.canDeleteAnyCollection(flexibleCollectionsV1Enabled) ||
super.canDelete(org, flexibleCollectionsV1Enabled)
);
}
/**

View File

@ -47,7 +47,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@ -61,7 +60,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/respon
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons } from "@bitwarden/components";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
@ -167,7 +166,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private platformUtilsService: PlatformUtilsService,
private broadcasterService: BroadcasterService,
private ngZone: NgZone,
private stateService: StateService,
private organizationService: OrganizationService,
private vaultFilterService: VaultFilterService,
private routedVaultFilterService: RoutedVaultFilterService,
@ -184,6 +182,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private apiService: ApiService,
private userVerificationService: UserVerificationService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
protected kdfConfigService: KdfConfigService,
) {}
@ -347,7 +346,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
null,
this.i18nService.t("unknownCipher"),
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@ -551,6 +550,12 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async shareCipher(cipher: CipherView) {
if ((await this.flexibleCollectionsV1Enabled()) && cipher.organizationId != null) {
// You cannot move ciphers between organizations
this.showMissingPermissionsError();
return;
}
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
return;
@ -650,7 +655,7 @@ export class VaultComponent implements OnInit, OnDestroy {
.sort(Utils.getSortFunction(this.i18nService, "name"))[0].id,
parentCollectionId: this.filter.collectionId,
showOrgSelector: true,
collectionIds: this.allCollections.map((c) => c.id),
limitNestedCollections: true,
},
});
const result = await lastValueFrom(dialog.closed);
@ -666,7 +671,12 @@ export class VaultComponent implements OnInit, OnDestroy {
async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: c?.id, organizationId: c.organizationId, initialTab: tab },
data: {
collectionId: c?.id,
organizationId: c.organizationId,
initialTab: tab,
limitNestedCollections: true,
},
});
const result = await lastValueFrom(dialog.closed);
@ -695,11 +705,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const organization = await this.organizationService.get(collection.organizationId);
const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$);
if (!collection.canDelete(organization, flexibleCollectionsV1Enabled)) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions"),
);
this.showMissingPermissionsError();
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
@ -750,11 +756,16 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async restore(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
if (!c.isDeleted) {
return;
}
if (!c.isDeleted) {
if ((await this.flexibleCollectionsV1Enabled()) && !c.edit) {
this.showMissingPermissionsError();
return;
}
if (!(await this.repromptCipher([c]))) {
return;
}
@ -768,17 +779,18 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async bulkRestore(ciphers: CipherView[]) {
if ((await this.flexibleCollectionsV1Enabled()) && ciphers.some((c) => !c.edit)) {
this.showMissingPermissionsError();
return;
}
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
@ -812,6 +824,11 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
if ((await this.flexibleCollectionsV1Enabled()) && !c.edit) {
this.showMissingPermissionsError();
return;
}
const permanent = c.isDeleted;
const confirmed = await this.dialogService.openSimpleDialog({
@ -847,13 +864,27 @@ export class VaultComponent implements OnInit, OnDestroy {
}
if (ciphers.length === 0 && collections.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
const flexibleCollectionsV1Enabled = await this.flexibleCollectionsV1Enabled();
const canDeleteCollections =
collections == null ||
collections.every((c) =>
c.canDelete(
organizations.find((o) => o.id == c.organizationId),
flexibleCollectionsV1Enabled,
),
);
const canDeleteCiphers = ciphers == null || ciphers.every((c) => c.edit);
if (flexibleCollectionsV1Enabled && (!canDeleteCollections || !canDeleteCiphers)) {
this.showMissingPermissionsError();
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.filter.type === "trash",
@ -876,11 +907,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
@ -950,12 +977,17 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
if (
(await this.flexibleCollectionsV1Enabled()) &&
ciphers.some((c) => c.organizationId != null)
) {
// You cannot move ciphers between organizations
this.showMissingPermissionsError();
return;
}
if (ciphers.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
@ -1011,6 +1043,18 @@ export class VaultComponent implements OnInit, OnDestroy {
replaceUrl: true,
});
}
private showMissingPermissionsError() {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("missingPermissions"),
});
}
private flexibleCollectionsV1Enabled() {
return firstValueFrom(this.flexibleCollectionsV1Enabled$);
}
}
/**

View File

@ -70,6 +70,13 @@ export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnIni
) {}
async ngOnInit() {
// If no ciphers are passed in, close the dialog
if (this.params.ciphers == null || this.params.ciphers.length < 1) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled);
return;
}
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
const restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
@ -86,12 +93,9 @@ export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnIni
// If no ciphers are editable, close the dialog
if (this.editableItemCount == 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled);
return;
}
this.totalItemCount = this.params.ciphers.length;

View File

@ -59,7 +59,7 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons } from "@bitwarden/components";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { GroupService, GroupView } from "../../admin-console/organizations/core";
@ -152,7 +152,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* A list of collections that the user can assign items to and edit those items within.
* @protected
*/
protected editableCollections$: Observable<CollectionView[]>;
protected editableCollections$: Observable<CollectionAdminView[]>;
protected allCollectionsWithoutUnassigned$: Observable<CollectionAdminView[]>;
private _flexibleCollectionsV1FlagEnabled: boolean;
@ -200,6 +200,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private collectionService: CollectionService,
private organizationUserService: OrganizationUserService,
protected configService: ConfigService,
private toastService: ToastService,
) {}
async ngOnInit() {
@ -567,11 +568,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (canEditCipher) {
await this.editCipherId(cipherId);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("unknownCipher"));
await this.router.navigate([], {
queryParams: { cipherId: null, itemId: null },
queryParamsHandling: "merge",
@ -596,11 +593,7 @@ export class VaultComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.viewEvents(cipher);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("unknownCipher"));
// 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([], {
@ -765,7 +758,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} else if (event.type === "viewCollectionAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access, event.readonly);
} else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items);
await this.bulkEditCollectionAccess(event.items, this.organization);
} else if (event.type === "assignToCollections") {
await this.bulkAssignToCollections(event.items);
} else if (event.type === "viewEvents") {
@ -817,7 +810,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async editCipherCollections(cipher: CipherView) {
let collections: CollectionView[] = [];
let collections: CollectionAdminView[] = [];
if (this.flexibleCollectionsV1Enabled) {
// V1 limits admins to only adding items to collections they have access to.
@ -978,11 +971,20 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async restore(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
if (!c.isDeleted) {
return;
}
if (!c.isDeleted) {
if (
this.flexibleCollectionsV1Enabled &&
!c.edit &&
!this.organization.allowAdminAccessToAllCollectionItems
) {
this.showMissingPermissionsError();
return;
}
if (!(await this.repromptCipher([c]))) {
return;
}
@ -997,17 +999,21 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async bulkRestore(ciphers: CipherView[]) {
if (
this.flexibleCollectionsV1Enabled &&
ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems)
) {
this.showMissingPermissionsError();
return;
}
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
@ -1017,6 +1023,15 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async deleteCipher(c: CipherView): Promise<boolean> {
if (
this.flexibleCollectionsV1Enabled &&
!c.edit &&
!this.organization.allowAdminAccessToAllCollectionItems
) {
this.showMissingPermissionsError();
return;
}
if (!(await this.repromptCipher([c]))) {
return;
}
@ -1048,11 +1063,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async deleteCollection(collection: CollectionView): Promise<void> {
if (!collection.canDelete(this.organization, this.flexibleCollectionsV1Enabled)) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions"),
);
this.showMissingPermissionsError();
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
@ -1097,13 +1108,23 @@ export class VaultComponent implements OnInit, OnDestroy {
}
if (ciphers.length === 0 && collections.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
const canDeleteCollections =
collections == null ||
collections.every((c) => c.canDelete(organization, this.flexibleCollectionsV1Enabled));
const canDeleteCiphers =
ciphers == null ||
this.organization.allowAdminAccessToAllCollectionItems ||
ciphers.every((c) => c.edit);
if (this.flexibleCollectionsV1Enabled && (!canDeleteCiphers || !canDeleteCollections)) {
this.showMissingPermissionsError();
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.filter.type === "trash",
@ -1175,6 +1196,9 @@ export class VaultComponent implements OnInit, OnDestroy {
data: {
organizationId: this.organization?.id,
parentCollectionId: this.selectedCollection?.node.id,
limitNestedCollections: !this.organization.canEditAnyCollection(
this.flexibleCollectionsV1Enabled,
),
},
});
@ -1198,6 +1222,9 @@ export class VaultComponent implements OnInit, OnDestroy {
organizationId: this.organization?.id,
initialTab: tab,
readonly: readonly,
limitNestedCollections: !this.organization.canEditAnyCollection(
this.flexibleCollectionsV1Enabled,
),
},
});
@ -1222,13 +1249,24 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkEditCollectionAccess(collections: CollectionView[]): Promise<void> {
async bulkEditCollectionAccess(
collections: CollectionView[],
organization: Organization,
): Promise<void> {
if (collections.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("noCollectionsSelected"),
});
return;
}
if (
this.flexibleCollectionsV1Enabled &&
collections.some((c) => !c.canEdit(organization, this.flexibleCollectionsV1Enabled))
) {
this.showMissingPermissionsError();
return;
}
@ -1247,11 +1285,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async bulkAssignToCollections(items: CipherView[]) {
if (items.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
return;
}
@ -1332,6 +1366,14 @@ export class VaultComponent implements OnInit, OnDestroy {
}
protected readonly CollectionDialogTabType = CollectionDialogTabType;
private showMissingPermissionsError() {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("missingPermissions"),
});
}
}
/**

View File

@ -8186,5 +8186,29 @@
},
"viewAccess": {
"message": "View access"
},
"noCollectionsSelected": {
"message": "You have not selected any collections."
},
"updateName": {
"message": "Update name"
},
"updatedOrganizationName": {
"message": "Updated organization name"
},
"providerPlan": {
"message": "Managed Service Provider"
},
"orgSeats": {
"message": "Organization Seats"
},
"providerDiscount": {
"message": "$AMOUNT$% Discount",
"placeholders": {
"amount": {
"content": "$1",
"example": "2"
}
}
}
}

View File

@ -12,8 +12,9 @@ import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import { ProviderSubscriptionComponent } from "../../billing/providers";
import {
CreateClientOrganizationComponent,
ManageClientOrganizationSubscriptionComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationSubscriptionComponent,
} from "../../billing/providers/clients";
import { AddOrganizationComponent } from "./clients/add-organization.component";
@ -62,6 +63,7 @@ import { SetupComponent } from "./setup/setup.component";
UserAddEditComponent,
CreateClientOrganizationComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationSubscriptionComponent,
ProviderSubscriptionComponent,
],

View File

@ -1,3 +1,4 @@
export * from "./create-client-organization.component";
export * from "./manage-client-organizations.component";
export * from "./manage-client-organization-name.component";
export * from "./manage-client-organization-subscription.component";

View File

@ -0,0 +1,24 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
{{ "updateName" | i18n }}
<small class="tw-text-muted">{{ dialogParams.organization.name }}</small>
</span>
<div bitDialogContent>
<bit-form-field>
<bit-label>
{{ "organizationName" | i18n }}
</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,77 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
type ManageClientOrganizationNameParams = {
providerId: string;
organization: {
id: string;
name: string;
seats: number;
};
};
export enum ManageClientOrganizationNameResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openManageClientOrganizationNameDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<ManageClientOrganizationNameParams>,
) =>
dialogService.open<ManageClientOrganizationNameResultType, ManageClientOrganizationNameParams>(
ManageClientOrganizationNameComponent,
dialogConfig,
);
@Component({
selector: "app-manage-client-organization-name",
templateUrl: "manage-client-organization-name.component.html",
})
export class ManageClientOrganizationNameComponent {
protected ResultType = ManageClientOrganizationNameResultType;
protected formGroup = this.formBuilder.group({
name: [this.dialogParams.organization.name, Validators.required],
});
constructor(
@Inject(DIALOG_DATA) protected dialogParams: ManageClientOrganizationNameParams,
private billingApiService: BillingApiServiceAbstraction,
private dialogRef: DialogRef<ManageClientOrganizationNameResultType>,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private toastService: ToastService,
) {}
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
const request = new UpdateClientOrganizationRequest();
request.assignedSeats = this.dialogParams.organization.seats;
request.name = this.formGroup.value.name;
await this.billingApiService.updateClientOrganization(
this.dialogParams.providerId,
this.dialogParams.organization.id,
request,
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedOrganizationName"),
});
this.dialogRef.close(this.ResultType.Submitted);
};
}

View File

@ -71,6 +71,7 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
const request = new UpdateClientOrganizationRequest();
request.assignedSeats = assignedSeats;
request.name = this.clientName;
await this.billingApiService.updateClientOrganization(
this.providerId,

View File

@ -78,8 +78,12 @@
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="manageName(client)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
{{ "updateName" | i18n }}
</button>
<button type="button" bitMenuItem (click)="manageSubscription(client)">
<i aria-hidden="true" class="bwi bwi-question-circle"></i>
<i aria-hidden="true" class="bwi bwi-family"></i>
{{ "manageSubscription" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(client)">

View File

@ -24,6 +24,10 @@ import {
CreateClientOrganizationResultType,
openCreateClientOrganizationDialog,
} from "./create-client-organization.component";
import {
ManageClientOrganizationNameResultType,
openManageClientOrganizationNameDialog,
} from "./manage-client-organization-name.component";
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
@Component({
@ -106,6 +110,25 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
this.loading = false;
}
async manageName(organization: ProviderOrganizationOrganizationDetailsResponse) {
const dialogRef = openManageClientOrganizationNameDialog(this.dialogService, {
data: {
providerId: this.providerId,
organization: {
id: organization.id,
name: organization.organizationName,
seats: organization.seats,
},
},
});
const result = await firstValueFrom(dialogRef.closed);
if (result === ManageClientOrganizationNameResultType.Submitted) {
await this.load();
}
}
async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) {
if (organization == null) {
return;
@ -135,4 +158,6 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
await this.load();
};
protected readonly openManageClientOrganizationNameDialog =
openManageClientOrganizationNameDialog;
}

View File

@ -1 +1,83 @@
<app-header></app-header>
<bit-container>
<ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="subscription && firstLoaded">
<bit-callout type="warning" title="{{ 'canceled' | i18n }}" *ngIf="false">
{{ "subscriptionCanceled" | i18n }}</bit-callout
>
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ "providerPlan" | i18n }}</dd>
<ng-container *ngIf="subscription">
<dt>{{ "status" | i18n }}</dt>
<dd>
<span class="tw-capitalize">{{ subscription.status }}</span>
</dd>
<dt [ngClass]="{ 'tw-text-danger': isExpired }">{{ "nextCharge" | i18n }}</dt>
<dd [ngClass]="{ 'tw-text-danger': isExpired }">
{{ subscription.currentPeriodEndDate | date: "mediumDate" }}
</dd>
</ng-container>
</dl>
</ng-container>
<ng-container>
<div class="tw-flex-col">
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2"
>{{ "details" | i18n }} &#160;<span
bitBadge
variant="success"
*ngIf="subscription.discountPercentage"
>{{ "providerDiscount" | i18n: subscription.discountPercentage }}</span
>
</strong>
<bit-table>
<ng-template body>
<ng-container *ngIf="subscription">
<tr bitRow *ngFor="let i of subscription.plans">
<td bitCell class="tw-pl-0 tw-py-3">
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
i.cadence.toLowerCase()
}}) {{ "&times;" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
@
{{
getFormattedCost(
i.cost,
i.seatMinimum,
i.purchasedSeats,
subscription.discountPercentage
) | currency: "$"
}}
</td>
<td bitCell class="tw-text-right tw-py-3">
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{
"month" | i18n
}}
<div>
<bit-hint class="tw-text-sm tw-line-through">
{{ i.cost | currency: "$" }} /{{ "month" | i18n }}
</bit-hint>
</div>
</td>
</tr>
<tr bitRow>
<td bitCell class="tw-pl-0 tw-py-3"></td>
<td bitCell class="tw-text-right">
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{
"month" | i18n
}}
</td>
</tr>
</ng-container>
</ng-template>
</bit-table>
</div>
</ng-container>
</bit-container>

View File

@ -1,7 +1,86 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, concatMap, takeUntil } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import {
Plans,
ProviderSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/provider-subscription-response";
@Component({
selector: "app-provider-subscription",
templateUrl: "./provider-subscription.component.html",
})
export class ProviderSubscriptionComponent {}
export class ProviderSubscriptionComponent {
subscription: ProviderSubscriptionResponse;
providerId: string;
firstLoaded = false;
loading: boolean;
private destroy$ = new Subject<void>();
totalCost: number;
currentDate = new Date();
constructor(
private billingApiService: BillingApiServiceAbstraction,
private route: ActivatedRoute,
) {}
async ngOnInit() {
this.route.params
.pipe(
concatMap(async (params) => {
this.providerId = params.providerId;
await this.load();
this.firstLoaded = true;
}),
takeUntil(this.destroy$),
)
.subscribe();
}
get isExpired() {
return this.subscription.status !== "active";
}
async load() {
if (this.loading) {
return;
}
this.loading = true;
this.subscription = await this.billingApiService.getProviderSubscription(this.providerId);
this.totalCost =
((100 - this.subscription.discountPercentage) / 100) * this.sumCost(this.subscription.plans);
this.loading = false;
}
getFormattedCost(
cost: number,
seatMinimum: number,
purchasedSeats: number,
discountPercentage: number,
): number {
const costPerSeat = cost / (seatMinimum + purchasedSeats);
const discountedCost = costPerSeat - (costPerSeat * discountPercentage) / 100;
return discountedCost;
}
getFormattedPlanName(planName: string): string {
const spaceIndex = planName.indexOf(" ");
return planName.substring(0, spaceIndex);
}
getFormattedSeatCount(seatMinimum: number, purchasedSeats: number): string {
const totalSeats = seatMinimum + purchasedSeats;
return totalSeats > 1 ? totalSeats.toString() : "";
}
sumCost(plans: Plans[]): number {
return plans.reduce((acc, plan) => acc + plan.cost, 0);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -631,6 +631,7 @@ const safeProviders: SafeProvider[] = [
LOGOUT_CALLBACK,
BillingAccountProfileStateService,
TokenServiceAbstraction,
AuthServiceAbstraction,
],
}),
safeProvider({

View File

@ -1,5 +1,5 @@
<main
class="tw-flex tw-min-h-screen tw-w-full tw-mx-auto tw-flex-col tw-gap-9 tw-px-4 tw-pb-4 tw-pt-14 tw-text-main"
class="tw-flex tw-min-h-screen tw-w-full tw-mx-auto tw-flex-col tw-gap-9 tw-bg-background-alt tw-px-4 tw-pb-4 tw-pt-14 tw-text-main"
>
<div class="tw-text-center">
<div class="tw-px-8">
@ -15,7 +15,7 @@
</div>
<div class="tw-mb-auto tw-mx-auto tw-flex tw-flex-col tw-items-center">
<div
class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
>
<ng-content></ng-content>
</div>

View File

@ -119,6 +119,7 @@ export class AccountServiceImplementation implements InternalAccountService {
}
async switchAccount(userId: UserId): Promise<void> {
let updateActivity = false;
await this.activeAccountIdState.update(
(_, accounts) => {
if (userId == null) {
@ -129,6 +130,7 @@ export class AccountServiceImplementation implements InternalAccountService {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
updateActivity = true;
return userId;
},
{
@ -139,6 +141,10 @@ export class AccountServiceImplementation implements InternalAccountService {
},
},
);
if (updateActivity) {
await this.setAccountActivity(userId, new Date());
}
}
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {

View File

@ -1,3 +1,4 @@
export class UpdateClientOrganizationRequest {
assignedSeats: number;
name: string;
}

View File

@ -1,4 +1,4 @@
import { sequentialize } from "./sequentialize";
import { clearCaches, sequentialize } from "./sequentialize";
describe("sequentialize decorator", () => {
it("should call the function once", async () => {
@ -100,6 +100,18 @@ describe("sequentialize decorator", () => {
allRes.sort();
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
});
describe("clearCaches", () => {
it("should clear all caches", async () => {
const foo = new Foo();
const promise = Promise.all([foo.bar(1), foo.bar(1)]);
clearCaches();
await foo.bar(1);
await promise;
// one call for the first two, one for the third after the cache was cleared
expect(foo.calls).toBe(2);
});
});
});
class Foo {

View File

@ -1,3 +1,19 @@
const caches = new Map<any, Map<string, Promise<any>>>();
const getCache = (obj: any) => {
let cache = caches.get(obj);
if (cache != null) {
return cache;
}
cache = new Map<string, Promise<any>>();
caches.set(obj, cache);
return cache;
};
export function clearCaches() {
caches.clear();
}
/**
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
*
@ -11,17 +27,6 @@
export function sequentialize(cacheKey: (args: any[]) => string) {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod: () => Promise<any> = descriptor.value;
const caches = new Map<any, Map<string, Promise<any>>>();
const getCache = (obj: any) => {
let cache = caches.get(obj);
if (cache != null) {
return cache;
}
cache = new Map<string, Promise<any>>();
caches.set(obj, cache);
return cache;
};
return {
value: function (...args: any[]) {

View File

@ -2,8 +2,14 @@ import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { Utils } from "../../../platform/misc/utils";
import { CipherService } from "../../abstractions/cipher.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";
import { CipherType } from "../../../vault/enums/cipher-type";
import { Cipher } from "../../../vault/models/domain/cipher";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import { LoginView } from "../../../vault/models/view/login.view";
import {
Fido2AuthenticatorErrorCode,
Fido2AuthenticatorGetAssertionParams,
@ -14,13 +20,7 @@ import {
Fido2UserInterfaceSession,
NewCredentialParams,
} from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
import { SyncService } from "../../abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { Cipher } from "../../models/domain/cipher";
import { CipherView } from "../../models/view/cipher.view";
import { Fido2CredentialView } from "../../models/view/fido2-credential.view";
import { LoginView } from "../../models/view/login.view";
import { Utils } from "../../misc/utils";
import { CBOR } from "./cbor";
import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service";

View File

@ -1,6 +1,9 @@
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { CipherService } from "../../abstractions/cipher.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";
import { CipherType } from "../../../vault/enums/cipher-type";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import {
Fido2AlgorithmIdentifier,
Fido2AuthenticatorError,
@ -13,11 +16,8 @@ import {
PublicKeyCredentialDescriptor,
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
import { SyncService } from "../../abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherView } from "../../models/view/cipher.view";
import { Fido2CredentialView } from "../../models/view/fido2-credential.view";
import { LogService } from "../../abstractions/log.service";
import { Utils } from "../../misc/utils";
import { CBOR } from "./cbor";
import { p1363ToDer } from "./ecdsa-utils";

View File

@ -4,8 +4,8 @@ import { of } from "rxjs";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { Utils } from "../../../platform/misc/utils";
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
import { ConfigService } from "../../abstractions/config/config.service";
import {
Fido2AuthenticatorError,
Fido2AuthenticatorErrorCode,
@ -17,7 +17,7 @@ import {
CreateCredentialParams,
FallbackRequestedError,
} from "../../abstractions/fido2/fido2-client.service.abstraction";
import { VaultSettingsService } from "../../abstractions/vault-settings/vault-settings.service";
import { Utils } from "../../misc/utils";
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
import { Fido2ClientService } from "./fido2-client.service";

View File

@ -4,9 +4,8 @@ import { parse } from "tldts";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
import { ConfigService } from "../../abstractions/config/config.service";
import {
Fido2AuthenticatorError,
Fido2AuthenticatorErrorCode,
@ -26,7 +25,8 @@ import {
UserRequestedFallbackAbortReason,
UserVerification,
} from "../../abstractions/fido2/fido2-client.service.abstraction";
import { VaultSettingsService } from "../../abstractions/vault-settings/vault-settings.service";
import { LogService } from "../../abstractions/log.service";
import { Utils } from "../../misc/utils";
import { isValidRpId } from "./domain-utils";
import { Fido2Utils } from "./fido2-utils";

View File

@ -61,7 +61,10 @@ export class CollectionView implements View, ITreeNodeObject {
return org?.canEditAnyCollection(false) || (org?.canEditAssignedCollections && this.assigned);
}
// For editing collection details, not the items within it.
/**
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
* After FCv1, does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/
canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
@ -69,12 +72,18 @@ export class CollectionView implements View, ITreeNodeObject {
);
}
return org?.flexibleCollections
? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage
: org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || org?.canEditAssignedCollections;
if (flexibleCollectionsV1Enabled) {
// Only use individual permissions, not admin permissions
return this.manage;
}
return org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage;
}
// For deleting a collection, not the items within it.
/**
* Returns true if the user can delete a collection from the individual vault.
* After FCv1, does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/
canDelete(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
@ -83,6 +92,12 @@ export class CollectionView implements View, ITreeNodeObject {
}
const canDeleteManagedCollections = !org?.limitCollectionCreationDeletion || org.isAdmin;
if (flexibleCollectionsV1Enabled) {
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage;
}
return (
org?.canDeleteAnyCollection(flexibleCollectionsV1Enabled) ||
(canDeleteManagedCollections && this.manage)

View File

@ -28,7 +28,7 @@ import {
DerivedState,
StateProvider,
} from "../../platform/state";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { UserKey, OrgKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
@ -136,11 +136,12 @@ export class CipherService implements CipherServiceAbstraction {
}
async setDecryptedCipherCache(value: CipherView[]) {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
// Sometimes we might prematurely decrypt the vault and that will result in no ciphers
// if we cache it then we may accidentially return it when it's not right, we'd rather try decryption again.
// if we cache it then we may accidentally return it when it's not right, we'd rather try decryption again.
// We still want to set null though, that is the indicator that the cache isn't valid and we should do decryption.
if (value == null || value.length !== 0) {
await this.setDecryptedCiphers(value);
await this.setDecryptedCiphers(value, userId);
}
if (this.searchService != null) {
if (value == null) {
@ -151,15 +152,16 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async setDecryptedCiphers(value: CipherView[]) {
private async setDecryptedCiphers(value: CipherView[], userId: UserId) {
const cipherViews: { [id: string]: CipherView } = {};
value?.forEach((c) => {
cipherViews[c.id] = c;
});
await this.decryptedCiphersState.update(() => cipherViews);
await this.stateProvider.setUserState(DECRYPTED_CIPHERS, cipherViews, userId);
}
async clearCache(userId?: string): Promise<void> {
async clearCache(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.clearDecryptedCiphersState(userId);
}
@ -524,6 +526,7 @@ export class CipherService implements CipherServiceAbstraction {
}
async updateLastUsedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
@ -553,10 +556,11 @@ export class CipherService implements CipherServiceAbstraction {
break;
}
}
await this.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache, userId);
}
async updateLastLaunchedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
@ -586,7 +590,7 @@ export class CipherService implements CipherServiceAbstraction {
break;
}
}
await this.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache, userId);
}
async saveNeverDomain(domain: string): Promise<void> {
@ -833,12 +837,18 @@ export class CipherService implements CipherServiceAbstraction {
await this.updateEncryptedCipherState(() => ciphers);
}
/**
* Updates ciphers for the currently active user. Inactive users can only clear all ciphers, for now.
* @param update update callback for encrypted cipher data
* @returns
*/
private async updateEncryptedCipherState(
update: (current: Record<CipherId, CipherData>) => Record<CipherId, CipherData>,
): Promise<Record<CipherId, CipherData>> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
// Store that we should wait for an update to return any ciphers
await this.ciphersExpectingUpdate.forceValue(true);
await this.clearDecryptedCiphersState();
await this.clearDecryptedCiphersState(userId);
const [, updatedCiphers] = await this.encryptedCiphersState.update((current) => {
const result = update(current ?? {});
return result;
@ -846,7 +856,8 @@ export class CipherService implements CipherServiceAbstraction {
return updatedCiphers;
}
async clear(userId?: string): Promise<any> {
async clear(userId?: UserId): Promise<any> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.clearEncryptedCiphersState(userId);
await this.clearCache(userId);
}
@ -1464,12 +1475,12 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async clearEncryptedCiphersState(userId?: string) {
await this.encryptedCiphersState.update(() => ({}));
private async clearEncryptedCiphersState(userId: UserId) {
await this.stateProvider.setUserState(ENCRYPTED_CIPHERS, {}, userId);
}
private async clearDecryptedCiphersState(userId?: string) {
await this.setDecryptedCiphers(null);
private async clearDecryptedCiphersState(userId: UserId) {
await this.setDecryptedCiphers(null, userId);
this.clearSortedCiphers();
}

View File

@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, of, switchMap } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@ -12,10 +12,12 @@ import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { ProviderData } from "../../../admin-console/models/data/provider.data";
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AvatarService } from "../../../auth/abstractions/avatar.service";
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service";
@ -74,6 +76,7 @@ export class SyncService implements SyncServiceAbstraction {
private logoutCallback: (expired: boolean) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService,
private authService: AuthService,
) {}
async getLastSync(): Promise<Date> {
@ -247,7 +250,19 @@ export class SyncService implements SyncServiceAbstraction {
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
const [activeUserId, status] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
switchMap((a) => {
if (a == null) {
of([null, AuthenticationStatus.LoggedOut]);
}
return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s]));
}),
),
);
// Process only notifications for currently active user when user is not logged out
// TODO: once send service allows data manipulation of non-active users, this should process any received notification
if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) {
try {
const localSend = await firstValueFrom(this.sendService.get$(notification.id));
if (
@ -361,15 +376,14 @@ export class SyncService implements SyncServiceAbstraction {
private async setForceSetPasswordReasonIfNeeded(profileResponse: ProfileResponse) {
// The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated
if (profileResponse.forcePasswordReset) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
profileResponse.id,
);
}
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(profileResponse.id),
);
if (userDecryptionOptions === null || userDecryptionOptions === undefined) {