Merge branch 'main' of https://github.com/bitwarden/clients into PM-4960-migrate-verify-recover-delete-component
This commit is contained in:
commit
bc018381cb
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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> = {},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"];
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
type="checkbox"
|
||||
bitCheckbox
|
||||
appStopProp
|
||||
*ngIf="canDeleteCollection"
|
||||
*ngIf="showCheckbox"
|
||||
[disabled]="disabled"
|
||||
[checked]="checked"
|
||||
(change)="$event ? this.checkedToggled.next() : null"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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$);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 }}  <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()
|
||||
}}) {{ "×" }}{{ 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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -631,6 +631,7 @@ const safeProviders: SafeProvider[] = [
|
|||
LOGOUT_CALLBACK,
|
||||
BillingAccountProfileStateService,
|
||||
TokenServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export class UpdateClientOrganizationRequest {
|
||||
assignedSeats: number;
|
||||
name: string;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue