1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-29 04:17:41 +02:00

[PM-6426] Merging main into branch

This commit is contained in:
Cesar Gonzalez 2024-05-10 10:59:01 -05:00
commit 478b8e6f13
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
30 changed files with 536 additions and 151 deletions

View File

@ -26,7 +26,9 @@ export const fido2AuthGuard: CanActivateFn = async (
const authStatus = await authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Locked) {
routerService.setPreviousUrl(state.url);
// Appending fromLock=true to the query params to indicate that the user is being redirected from the lock screen, this is used for user verification.
const previousUrl = `${state.url}&fromLock=true`;
routerService.setPreviousUrl(previousUrl);
return router.createUrlTree(["/lock"], { queryParams: route.queryParams });
}

View File

@ -1,12 +1,16 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import BrowserPopupUtils from "../browser-popup-utils";
@Component({
selector: "app-pop-out",
templateUrl: "pop-out.component.html",
standalone: true,
imports: [CommonModule, JslibModule],
})
export class PopOutComponent implements OnInit {
@Input() show = true;
@ -24,9 +28,7 @@ export class PopOutComponent implements OnInit {
}
}
expand() {
// 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
BrowserPopupUtils.openCurrentPagePopout(window);
async expand() {
await BrowserPopupUtils.openCurrentPagePopout(window);
}
}

View File

@ -14,6 +14,7 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components";
import { ExportScopeCalloutComponent } from "@bitwarden/vault-export-ui";
@ -40,6 +41,7 @@ import { AutofillComponent } from "../autofill/popup/settings/autofill.component
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotifcationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component";
import { PopOutComponent } from "../platform/popup/components/pop-out.component";
import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
@ -80,7 +82,6 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { PopOutComponent } from "./components/pop-out.component";
import { UserVerificationComponent } from "./components/user-verification.component";
import { ServicesModule } from "./services/services.module";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
@ -117,10 +118,12 @@ import "../platform/popup/locales";
AccountComponent,
ButtonModule,
ExportScopeCalloutComponent,
PopOutComponent,
PopupPageComponent,
PopupTabNavigationComponent,
PopupFooterComponent,
PopupHeaderComponent,
UserVerificationDialogComponent,
],
declarations: [
ActionButtonsComponent,
@ -155,7 +158,6 @@ import "../platform/popup/locales";
GeneratorComponent,
PasswordGeneratorHistoryComponent,
PasswordHistoryComponent,
PopOutComponent,
PremiumComponent,
RegisterComponent,
SendAddEditComponent,

View File

@ -88,6 +88,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { UnauthGuardService } from "../../auth/popup/services";
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
@ -119,6 +120,7 @@ import { ForegroundMemoryStorageService } from "../../platform/storage/foregroun
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service";
import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service";
import { VaultFilterService } from "../../vault/services/vault-filter.service";
@ -602,6 +604,11 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE,
useValue: ClientType.Browser,
}),
safeProvider({
provide: Fido2UserVerificationService,
useClass: Fido2UserVerificationService,
deps: [PasswordRepromptService, UserVerificationService, DialogService],
}),
safeProvider({
provide: TaskSchedulerService,
useExisting: BrowserTaskSchedulerServiceImplementation,

View File

@ -27,13 +27,13 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service";
import {
BrowserFido2Message,
BrowserFido2UserInterfaceSession,
} from "../../../fido2/browser-fido2-user-interface.service";
import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service";
import { VaultPopoutType } from "../../utils/vault-popout-window";
interface ViewData {
@ -59,6 +59,7 @@ export class Fido2Component implements OnInit, OnDestroy {
protected data$: Observable<ViewData>;
protected sessionId?: string;
protected senderTabId?: string;
protected fromLock?: boolean;
protected ciphers?: CipherView[] = [];
protected displayedCiphers?: CipherView[] = [];
protected loading = false;
@ -71,13 +72,13 @@ export class Fido2Component implements OnInit, OnDestroy {
private router: Router,
private activatedRoute: ActivatedRoute,
private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService,
private platformUtilsService: PlatformUtilsService,
private domainSettingsService: DomainSettingsService,
private searchService: SearchService,
private logService: LogService,
private dialogService: DialogService,
private browserMessagingApi: ZonedMessageListenerService,
private fido2UserVerificationService: Fido2UserVerificationService,
) {}
ngOnInit() {
@ -89,6 +90,7 @@ export class Fido2Component implements OnInit, OnDestroy {
sessionId: queryParamMap.get("sessionId"),
senderTabId: queryParamMap.get("senderTabId"),
senderUrl: queryParamMap.get("senderUrl"),
fromLock: queryParamMap.get("fromLock"),
})),
);
@ -101,6 +103,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.sessionId = queryParams.sessionId;
this.senderTabId = queryParams.senderTabId;
this.url = queryParams.senderUrl;
this.fromLock = queryParams.fromLock === "true";
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if (
message.type === "NewSessionCreatedRequest" &&
@ -210,7 +213,11 @@ export class Fido2Component implements OnInit, OnDestroy {
protected async submit() {
const data = this.message$.value;
if (data?.type === "PickCredentialRequest") {
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
this.send({
sessionId: this.sessionId,
@ -231,7 +238,11 @@ export class Fido2Component implements OnInit, OnDestroy {
}
}
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
this.send({
sessionId: this.sessionId,
@ -248,14 +259,21 @@ export class Fido2Component implements OnInit, OnDestroy {
const data = this.message$.value;
if (data?.type === "ConfirmNewCredentialRequest") {
const name = data.credentialName || data.rpId;
await this.createNewCipher(name);
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
if (!data.userVerification || userVerified) {
await this.createNewCipher(name);
}
// We are bypassing user verification pending implementation of PIN and biometric support.
this.send({
sessionId: this.sessionId,
cipherId: this.cipher?.id,
type: "ConfirmNewCredentialResponse",
userVerified: data.userVerification,
userVerified,
});
}
@ -304,6 +322,7 @@ export class Fido2Component implements OnInit, OnDestroy {
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
fromLock: this.fromLock,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
@ -374,20 +393,6 @@ export class Fido2Component implements OnInit, OnDestroy {
}
}
private async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
if (masterPasswordRepromptRequired) {
return await this.passwordRepromptService.showPasswordPrompt();
}
// We are bypassing user verification pending implementation of PIN and biometric support.
return userVerificationRequested;
}
private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId,

View File

@ -30,6 +30,7 @@ import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service";
import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service";
import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service";
import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data";
import { closeAddEditVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window";
@ -69,6 +70,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService: DialogService,
datePipe: DatePipe,
configService: ConfigService,
private fido2UserVerificationService: Fido2UserVerificationService,
) {
super(
cipherService,
@ -168,11 +170,17 @@ export class AddEditComponent extends BaseAddEditComponent {
async submit(): Promise<boolean> {
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
const { isFido2Session, sessionId, userVerification, fromLock } = fido2SessionData;
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
if (
inFido2PopoutWindow &&
!(await this.handleFido2UserVerification(sessionId, userVerification))
userVerification &&
!(await this.fido2UserVerificationService.handleUserVerification(
userVerification,
this.cipher,
fromLock,
))
) {
return false;
}
@ -327,14 +335,6 @@ export class AddEditComponent extends BaseAddEditComponent {
}, 200);
}
private async handleFido2UserVerification(
sessionId: string,
userVerification: boolean,
): Promise<boolean> {
// We are bypassing user verification pending implementation of PIN and biometric support.
return true;
}
repromptChanged() {
super.repromptChanged();

View File

@ -16,6 +16,7 @@ export function fido2PopoutSessionData$() {
fallbackSupported: queryParams.fallbackSupported === "true",
userVerification: queryParams.userVerification === "true",
senderUrl: queryParams.senderUrl as string,
fromLock: queryParams.fromLock === "true",
})),
);
}

View File

@ -0,0 +1,248 @@
import { MockProxy, mock } from "jest-mock-extended";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { SetPinComponent } from "./../../auth/popup/components/set-pin.component";
import { Fido2UserVerificationService } from "./fido2-user-verification.service";
jest.mock("@bitwarden/auth/angular", () => ({
UserVerificationDialogComponent: {
open: jest.fn().mockResolvedValue({ userAction: "confirm", verificationSuccess: true }),
},
}));
jest.mock("../../auth/popup/components/set-pin.component", () => {
return {
SetPinComponent: {
open: jest.fn(),
},
};
});
describe("Fido2UserVerificationService", () => {
let fido2UserVerificationService: Fido2UserVerificationService;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let userVerificationService: MockProxy<UserVerificationService>;
let dialogService: MockProxy<DialogService>;
let cipher: CipherView;
beforeEach(() => {
passwordRepromptService = mock<PasswordRepromptService>();
userVerificationService = mock<UserVerificationService>();
dialogService = mock<DialogService>();
cipher = createCipherView();
fido2UserVerificationService = new Fido2UserVerificationService(
passwordRepromptService,
userVerificationService,
dialogService,
);
(UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({
userAction: "confirm",
verificationSuccess: true,
});
});
describe("handleUserVerification", () => {
describe("user verification requested is true", () => {
it("should return true if user is redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen and no master password reprompt is required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should prompt user to set pin if user has no verification method", async () => {
(UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({
userAction: "confirm",
verificationSuccess: false,
noAvailableClientVerificationMethods: true,
});
await fido2UserVerificationService.handleUserVerification(true, cipher, false);
expect(SetPinComponent.open).toHaveBeenCalledWith(dialogService);
});
});
describe("user verification requested is false", () => {
it("should return false if user is redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(result).toBe(false);
});
it("should return false if user is not redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(result).toBe(false);
});
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(result).toBe(true);
expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled();
});
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
});
});
});
function createCipherView() {
const cipher = new CipherView();
cipher.id = Utils.newGuid();
cipher.type = CipherType.Login;
cipher.reprompt = CipherRepromptType.None;
return cipher;
}

View File

@ -0,0 +1,101 @@
import { firstValueFrom } from "rxjs";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { SetPinComponent } from "../../auth/popup/components/set-pin.component";
export class Fido2UserVerificationService {
constructor(
private passwordRepromptService: PasswordRepromptService,
private userVerificationService: UserVerificationService,
private dialogService: DialogService,
) {}
/**
* Handles user verification for a user based on the cipher and user verification requested.
* @param userVerificationRequested Indicates if user verification is required or not.
* @param cipher Contains details about the cipher including master password reprompt.
* @param fromLock Indicates if the request is from the lock screen.
* @returns
*/
async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
fromLock: boolean,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
// If the request is from the lock screen, treat unlocking the vault as user verification,
// unless a master password reprompt is required.
if (fromLock) {
return masterPasswordRepromptRequired
? await this.handleMasterPasswordReprompt()
: userVerificationRequested;
}
if (masterPasswordRepromptRequired) {
return await this.handleMasterPasswordReprompt();
}
if (userVerificationRequested) {
return await this.showUserVerificationDialog();
}
return userVerificationRequested;
}
private async showMasterPasswordReprompt(): Promise<boolean> {
return await this.passwordRepromptService.showPasswordPrompt();
}
private async showUserVerificationDialog(): Promise<boolean> {
const result = await UserVerificationDialogComponent.open(this.dialogService, {
clientSideOnlyVerification: true,
});
if (result.userAction === "cancel") {
return;
}
// Handle unsuccessful verification attempts.
if (!result.verificationSuccess) {
// Check if no client-side verification methods are available.
if (result.noAvailableClientVerificationMethods) {
return await this.promptUserToSetPin();
}
return;
}
return result.verificationSuccess;
}
private async handleMasterPasswordReprompt(): Promise<boolean> {
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
// TDE users have no master password, so we need to use the UserVerification prompt
return hasMasterPassword
? await this.showMasterPasswordReprompt()
: await this.showUserVerificationDialog();
}
private async promptUserToSetPin() {
const dialogRef = SetPinComponent.open(this.dialogService);
if (!dialogRef) {
return;
}
const userHasPinSet = await firstValueFrom(dialogRef.closed);
if (!userHasPinSet) {
return;
}
// If the user has set a PIN, re-invoke the user verification dialog to complete the verification process.
return await this.showUserVerificationDialog();
}
}

View File

@ -165,23 +165,4 @@ export class OrganizationUserResetPasswordService {
}
return requests;
}
/**
* @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data.
*/
async postLegacyRotation(
userId: string,
requests: OrganizationUserResetPasswordWithIdRequest[],
): Promise<void> {
if (requests == null) {
return;
}
for (const request of requests) {
await this.organizationUserService.putOrganizationUserResetPasswordEnrollment(
request.organizationId,
userId,
request,
);
}
}
}

View File

@ -328,16 +328,4 @@ export class EmergencyAccessService {
private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise<EncryptedString> {
return (await this.cryptoService.rsaEncrypt(userKey.key, publicKey)).encryptedString;
}
/**
* @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data.
*/
async postLegacyRotation(requests: EmergencyAccessWithIdRequest[]): Promise<void> {
if (requests == null) {
return;
}
for (const request of requests) {
await this.emergencyAccessApiService.putEmergencyAccess(request.id, request);
}
}
}

View File

@ -82,7 +82,6 @@ describe("KeyRotationService", () => {
mockEncryptService,
mockStateService,
mockAccountService,
mockConfigService,
mockKdfConfigService,
);
});
@ -191,16 +190,6 @@ describe("KeyRotationService", () => {
);
});
it("uses legacy rotation if feature flag is off", async () => {
mockConfigService.getFeatureFlag.mockResolvedValueOnce(false);
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
expect(mockEmergencyAccessService.postLegacyRotation).toHaveBeenCalled();
expect(mockResetPasswordService.postLegacyRotation).toHaveBeenCalled();
});
it("throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));

View File

@ -5,8 +5,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@ -39,7 +37,6 @@ export class UserKeyRotationService {
private encryptService: EncryptService,
private stateService: StateService,
private accountService: AccountService,
private configService: ConfigService,
private kdfConfigService: KdfConfigService,
) {}
@ -90,11 +87,7 @@ export class UserKeyRotationService {
request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey);
request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey);
if (await this.configService.getFeatureFlag(FeatureFlag.KeyRotationImprovements)) {
await this.apiService.postUserKeyUpdate(request);
} else {
await this.rotateUserKeyAndEncryptedDataLegacy(request);
}
await this.apiService.postUserKeyUpdate(request);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.rotateDevicesTrust(
@ -139,16 +132,4 @@ export class UserKeyRotationService {
}),
);
}
private async rotateUserKeyAndEncryptedDataLegacy(request: UpdateKeyRequest): Promise<void> {
// Update keys, ciphers, folders, and sends
await this.apiService.postUserKeyUpdate(request);
// Update emergency access keys
await this.emergencyAccessService.postLegacyRotation(request.emergencyAccessKeys);
// Update account recovery keys
const userId = await this.stateService.getUserId();
await this.resetPasswordService.postLegacyRotation(userId, request.resetPasswordKeys);
}
}

View File

@ -595,7 +595,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.formPromise = doSubmit();
const organizationId = await this.formPromise;
this.onSuccess.emit({ organizationId: organizationId });
this.messagingService.send("organizationCreated", organizationId);
// TODO: No one actually listening to this message?
this.messagingService.send("organizationCreated", { organizationId });
};
private async updateOrganization(orgId: string) {

View File

@ -58,7 +58,7 @@ export class VaultCipherRowComponent {
}
protected editCollections() {
this.onEvent.emit({ type: "viewCollections", item: this.cipher });
this.onEvent.emit({ type: "viewCipherCollections", item: this.cipher });
}
protected events() {

View File

@ -63,7 +63,7 @@
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
*ngIf="canEditCollection || canDeleteCollection"
*ngIf="canEditCollection || canDeleteCollection || canViewCollectionInfo"
[disabled]="disabled"
[bitMenuTriggerFor]="collectionOptions"
size="small"
@ -73,14 +73,28 @@
appStopProp
></button>
<bit-menu #collectionOptions>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="edit()">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="access()">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<ng-container *ngIf="canEditCollection">
<button type="button" bitMenuItem (click)="edit(false)">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button type="button" bitMenuItem (click)="access(false)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container
*ngIf="flexibleCollectionsV1Enabled && !canEditCollection && canViewCollectionInfo"
>
<button type="button" bitMenuItem (click)="edit(true)">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button type="button" bitMenuItem (click)="access(true)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button *ngIf="canDeleteCollection" type="button" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>

View File

@ -30,9 +30,11 @@ export class VaultCollectionRowComponent {
@Input() showGroups: boolean;
@Input() canEditCollection: boolean;
@Input() canDeleteCollection: boolean;
@Input() canViewCollectionInfo: boolean;
@Input() organizations: Organization[];
@Input() groups: GroupView[];
@Input() showPermissionsColumn: boolean;
@Input() flexibleCollectionsV1Enabled: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@ -71,12 +73,12 @@ export class VaultCollectionRowComponent {
return "";
}
protected edit() {
this.onEvent.next({ type: "editCollection", item: this.collection });
protected edit(readonly: boolean) {
this.onEvent.next({ type: "editCollection", item: this.collection, readonly: readonly });
}
protected access() {
this.onEvent.next({ type: "viewCollectionAccess", item: this.collection });
protected access(readonly: boolean) {
this.onEvent.next({ type: "viewCollectionAccess", item: this.collection, readonly: readonly });
}
protected deleteCollection() {

View File

@ -5,11 +5,11 @@ import { VaultItem } from "./vault-item";
export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView }
| { type: "viewCipherCollections"; item: CipherView }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewCollectionAccess"; item: CollectionView }
| { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean }
| { type: "viewEvents"; item: CipherView }
| { type: "editCollection"; item: CollectionView }
| { type: "editCollection"; item: CollectionView; readonly: boolean }
| { type: "clone"; item: CipherView }
| { type: "restore"; items: CipherView[] }
| { type: "delete"; items: VaultItem[] }

View File

@ -95,13 +95,15 @@
[groups]="allGroups"
[canDeleteCollection]="canDeleteCollection(item.collection)"
[canEditCollection]="canEditCollection(item.collection)"
[canViewCollectionInfo]="canViewCollectionInfo(item.collection)"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
[checked]="selection.isSelected(item)"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
></tr>
<!--
addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess
<!--
addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess
-->
<tr
*ngIf="item.cipher && (!addAccessToggle || (addAccessToggle && addAccessStatus !== 1))"

View File

@ -165,6 +165,11 @@ export class VaultItemsComponent {
return collection.canDelete(organization);
}
protected canViewCollectionInfo(collection: CollectionView) {
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canViewCollectionInfo(organization);
}
protected toggleAll() {
this.isAllSelected
? this.selection.clear()

View File

@ -4,6 +4,7 @@ import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/mod
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { CollectionAccessSelectionView } from "../../../admin-console/organizations/core/views/collection-access-selection.view";
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
@ -89,4 +90,19 @@ export class CollectionAdminView extends CollectionView {
canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups;
}
/**
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection) {
return false;
}
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
}
get isUnassignedCollection() {
return this.id === Unassigned;
}
}

View File

@ -434,7 +434,7 @@ export class VaultComponent implements OnInit, OnDestroy {
try {
if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") {
} else if (event.type === "viewCipherCollections") {
await this.editCipherCollections(event.item);
} else if (event.type === "clone") {
await this.cloneCipher(event.item);

View File

@ -37,24 +37,44 @@
aria-haspopup="true"
></button>
<bit-menu #editCollectionMenu>
<button
type="button"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info)"
<ng-container *ngIf="canEditCollection">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container
*ngIf="flexibleCollectionsV1Enabled && !canEditCollection && canViewCollectionInfo"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>

View File

@ -53,7 +53,10 @@ export class VaultHeaderComponent implements OnInit {
@Output() onAddCollection = new EventEmitter<void>();
/** Emits an event when the edit collection button is clicked in the header */
@Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType }>();
@Output() onEditCollection = new EventEmitter<{
tab: CollectionDialogTabType;
readonly: boolean;
}>();
/** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>();
@ -64,7 +67,7 @@ export class VaultHeaderComponent implements OnInit {
protected CollectionDialogTabType = CollectionDialogTabType;
protected organizations$ = this.organizationService.organizations$;
private flexibleCollectionsV1Enabled = false;
protected flexibleCollectionsV1Enabled = false;
private restrictProviderAccessFlag = false;
constructor(
@ -193,8 +196,8 @@ export class VaultHeaderComponent implements OnInit {
this.onAddCollection.emit();
}
async editCollection(tab: CollectionDialogTabType): Promise<void> {
this.onEditCollection.emit({ tab });
async editCollection(tab: CollectionDialogTabType, readonly: boolean): Promise<void> {
this.onEditCollection.emit({ tab, readonly });
}
get canDeleteCollection(): boolean {
@ -207,6 +210,10 @@ export class VaultHeaderComponent implements OnInit {
return this.collection.node.canDelete(this.organization);
}
get canViewCollectionInfo(): boolean {
return this.collection.node.canViewCollectionInfo(this.organization);
}
get canCreateCollection(): boolean {
return this.organization?.canCreateNewCollections;
}

View File

@ -6,7 +6,7 @@
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher()"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>

View File

@ -736,7 +736,7 @@ export class VaultComponent implements OnInit, OnDestroy {
try {
if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") {
} else if (event.type === "viewCipherCollections") {
await this.editCipherCollections(event.item);
} else if (event.type === "clone") {
await this.cloneCipher(event.item);
@ -761,9 +761,9 @@ export class VaultComponent implements OnInit, OnDestroy {
} else if (event.type === "copyField") {
await this.copy(event.item, event.field);
} else if (event.type === "editCollection") {
await this.editCollection(event.item, CollectionDialogTabType.Info);
await this.editCollection(event.item, CollectionDialogTabType.Info, event.readonly);
} else if (event.type === "viewCollectionAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access);
await this.editCollection(event.item, CollectionDialogTabType.Access, event.readonly);
} else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items);
} else if (event.type === "assignToCollections") {
@ -1190,7 +1190,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async editCollection(
c: CollectionView,
tab: CollectionDialogTabType,
readonly: boolean = false,
readonly: boolean,
): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, {
data: {

View File

@ -8081,5 +8081,11 @@
},
"manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal"
},
"viewInfo": {
"message": "View info"
},
"viewAccess": {
"message": "View access"
}
}

View File

@ -9,7 +9,6 @@ export enum FeatureFlag {
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
VaultOnboarding = "vault-onboarding",
GeneratorToolsModernization = "generator-tools-modernization",
KeyRotationImprovements = "key-rotation-improvements",
FlexibleCollectionsMigration = "flexible-collections-migration",
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
EnableConsolidatedBilling = "enable-consolidated-billing",
@ -37,7 +36,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.FlexibleCollectionsV1]: FALSE,
[FeatureFlag.VaultOnboarding]: FALSE,
[FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.KeyRotationImprovements]: FALSE,
[FeatureFlag.FlexibleCollectionsMigration]: FALSE,
[FeatureFlag.ShowPaymentMethodWarningBanners]: FALSE,
[FeatureFlag.EnableConsolidatedBilling]: FALSE,

View File

@ -87,6 +87,13 @@ export class CollectionView implements View, ITreeNodeObject {
: org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections;
}
/**
* Returns true if the user can view collection info and access in a read-only state from the individual vault
*/
canViewCollectionInfo(org: Organization | undefined): boolean {
return false;
}
static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView(new Collection()), obj);
}