mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 18:43:26 +00:00
Merge branch 'main' into sdk-pass-generation
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
block
|
||||
buttonType="secondary"
|
||||
class="tw-mt-4"
|
||||
(click)="startStandardAuthRequestLogin()"
|
||||
(click)="startStandardAuthRequestLogin(true)"
|
||||
>
|
||||
{{ "resendNotification" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -24,10 +22,13 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -40,6 +41,7 @@ import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
|
||||
import { LoginViaAuthRequestCacheService } from "../../common/services/auth-request/default-login-via-auth-request-cache.service";
|
||||
|
||||
enum Flow {
|
||||
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
|
||||
@@ -57,23 +59,26 @@ const matchOptions: IsActiveMatchOptions = {
|
||||
standalone: true,
|
||||
templateUrl: "./login-via-auth-request.component.html",
|
||||
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
|
||||
providers: [{ provide: LoginViaAuthRequestCacheService }],
|
||||
})
|
||||
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private authRequest: AuthRequest;
|
||||
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
|
||||
private authStatus: AuthenticationStatus;
|
||||
private authRequest: AuthRequest | undefined = undefined;
|
||||
private authRequestKeyPair:
|
||||
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
|
||||
| undefined = undefined;
|
||||
private authStatus: AuthenticationStatus | undefined = undefined;
|
||||
private showResendNotificationTimeoutSeconds = 12;
|
||||
|
||||
protected backToRoute = "/login";
|
||||
protected clientType: ClientType;
|
||||
protected ClientType = ClientType;
|
||||
protected email: string;
|
||||
protected fingerprintPhrase: string;
|
||||
protected email: string | undefined = undefined;
|
||||
protected fingerprintPhrase: string | undefined = undefined;
|
||||
protected showResendNotification = false;
|
||||
protected Flow = Flow;
|
||||
protected flow = Flow.StandardAuthRequest;
|
||||
protected webVaultUrl: string;
|
||||
protected deviceManagementUrl: string;
|
||||
protected webVaultUrl: string | undefined = undefined;
|
||||
protected deviceManagementUrl: string | undefined;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
@@ -95,6 +100,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private loginViaAuthRequestCacheService: LoginViaAuthRequestCacheService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
|
||||
@@ -124,6 +131,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
async ngOnInit(): Promise<void> {
|
||||
// Get the authStatus early because we use it in both flows
|
||||
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
await this.loginViaAuthRequestCacheService.init();
|
||||
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
@@ -133,7 +141,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* The LoginViaAuthRequestComponent handles both the `login-with-device` and
|
||||
* the `admin-approval-requested` routes. Therefore we check the route to determine
|
||||
* the `admin-approval-requested` routes. Therefore, we check the route to determine
|
||||
* which flow to initialize.
|
||||
*/
|
||||
if (this.router.isActive("admin-approval-requested", matchOptions)) {
|
||||
@@ -159,7 +167,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
// We only allow a single admin approval request to be active at a time
|
||||
// so we must check state to see if we have an existing one or not
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
"Not able to get a user id from the account service active account observable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
|
||||
|
||||
if (existingAdminAuthRequest) {
|
||||
@@ -172,7 +187,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private async initStandardAuthRequestFlow(): Promise<void> {
|
||||
this.flow = Flow.StandardAuthRequest;
|
||||
|
||||
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
this.email = (await firstValueFrom(this.loginEmailService.loginEmail$)) || undefined;
|
||||
|
||||
if (!this.email) {
|
||||
await this.handleMissingEmail();
|
||||
@@ -185,7 +200,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private async handleMissingEmail(): Promise<void> {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("userEmailMissing"),
|
||||
});
|
||||
|
||||
@@ -194,21 +208,41 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
await this.anonymousHubService.stopHubConnection();
|
||||
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
}
|
||||
|
||||
private async startAdminAuthRequestLogin(): Promise<void> {
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
||||
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("Auth request failed to build.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequestKeyPair) {
|
||||
this.logService.error("Key pairs failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
|
||||
this.authRequest,
|
||||
this.authRequest as AuthRequest,
|
||||
);
|
||||
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
||||
id: authRequestResponse.id,
|
||||
privateKey: this.authRequestKeyPair.privateKey,
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
"Not able to get a user id from the account service active account observable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
@@ -219,21 +253,104 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
protected async startStandardAuthRequestLogin(): Promise<void> {
|
||||
protected async startStandardAuthRequestLogin(
|
||||
clearCachedRequest: boolean = false,
|
||||
): Promise<void> {
|
||||
this.showResendNotification = false;
|
||||
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
||||
this.authRequest,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
|
||||
// Used for manually refreshing the auth request when clicking the resend auth request
|
||||
// on the ui.
|
||||
if (clearCachedRequest) {
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
}
|
||||
|
||||
try {
|
||||
const loginAuthRequestView: LoginViaAuthRequestView | null =
|
||||
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
|
||||
|
||||
if (!loginAuthRequestView) {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
// I tried several ways to get the IDE/linter to play nice with checking for null values
|
||||
// in less code / more efficiently, but it struggles to identify code paths that
|
||||
// are more complicated than this.
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fingerprintPhrase) {
|
||||
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequestKeyPair) {
|
||||
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse: AuthRequestResponse =
|
||||
await this.authRequestApiService.postAuthRequest(this.authRequest);
|
||||
|
||||
this.loginViaAuthRequestCacheService.cacheLoginView(
|
||||
this.authRequest,
|
||||
authRequestResponse,
|
||||
this.fingerprintPhrase,
|
||||
this.authRequestKeyPair,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} else {
|
||||
// Grab the cached information and store it back in component state.
|
||||
// We don't need the public key for handling the authentication request because
|
||||
// the verifyAndHandleApprovedAuthReq function will receive the public key back
|
||||
// from the looked up auth request and all we need is to make sure that
|
||||
// we can use the cached private key that is associated with it.
|
||||
this.authRequest = loginAuthRequestView.authRequest;
|
||||
this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
|
||||
this.authRequestKeyPair = {
|
||||
privateKey: loginAuthRequestView.privateKey
|
||||
? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
|
||||
: undefined,
|
||||
publicKey: undefined,
|
||||
};
|
||||
|
||||
if (!loginAuthRequestView.authRequestResponse) {
|
||||
this.logService.error("No cached auth request response.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginAuthRequestView.authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(
|
||||
loginAuthRequestView.authRequestResponse.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("No auth request found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
||||
this.authRequest,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -250,12 +367,23 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
|
||||
if (!this.authRequestKeyPair.publicKey) {
|
||||
this.logService.error("AuthRequest public key not set to value in building auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||
const accessCode = await this.passwordGenerationService.generatePassword({
|
||||
type: "password",
|
||||
length: 25,
|
||||
});
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when building auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
this.authRequestKeyPair.publicKey,
|
||||
@@ -288,6 +416,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
this.logService.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Request doesn't exist anymore
|
||||
@@ -300,6 +430,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
adminAuthRequestStorable.privateKey,
|
||||
);
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when handling an existing an admin auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
@@ -319,9 +455,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// Request still pending response from admin
|
||||
// set keypair and create hub connection so that any approvals will be received via push notification
|
||||
this.authRequestKeyPair = { privateKey: adminAuthRequestStorable.privateKey, publicKey: null };
|
||||
// Request still pending response from admin set keypair and create hub connection
|
||||
// so that any approvals will be received via push notification
|
||||
this.authRequestKeyPair = {
|
||||
privateKey: adminAuthRequestStorable.privateKey,
|
||||
publicKey: undefined,
|
||||
};
|
||||
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
|
||||
}
|
||||
|
||||
@@ -403,6 +542,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
await this.handleAuthenticatedFlows(authRequestResponse);
|
||||
}
|
||||
} else {
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("No auth request defined when handling approved auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the auth request from the server
|
||||
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
||||
@@ -423,11 +567,26 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.logService.error(error);
|
||||
} finally {
|
||||
// Manually clean out the cache to make sure sensitive
|
||||
// data does not persist longer than it needs to.
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
"Not able to get a user id from the account service active account observable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
|
||||
this.logService.error("No private key set when handling the authenticated flows.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.decryptViaApprovedAuthRequest(
|
||||
authRequestResponse,
|
||||
@@ -445,6 +604,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
authRequestResponse,
|
||||
);
|
||||
|
||||
if (!authRequestLoginCredentials) {
|
||||
this.logService.error("Didn't set up auth request login credentials properly.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: keys are set by AuthRequestLoginStrategy success handling
|
||||
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
|
||||
|
||||
@@ -463,7 +627,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
*/
|
||||
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 3
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
@@ -486,13 +649,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("loginApproved"),
|
||||
});
|
||||
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
this.logService.error("No active account defined from the account service.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
|
||||
|
||||
await this.handleSuccessfulLoginNavigation(userId);
|
||||
@@ -508,7 +675,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private async buildAuthRequestLoginCredentials(
|
||||
requestId: string,
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
): Promise<AuthRequestLoginCredentials> {
|
||||
): Promise<AuthRequestLoginCredentials | undefined> {
|
||||
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
|
||||
this.logService.error("No private key set when building auth request login credentials.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequest) {
|
||||
this.logService.error(
|
||||
"AuthRequest not defined when building auth request login credentials.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||
*
|
||||
@@ -516,7 +700,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
*/
|
||||
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 1
|
||||
const { masterKey, masterKeyHash } =
|
||||
|
||||
@@ -53,9 +53,9 @@ export class AuthRequestLoginCredentials {
|
||||
public email: string,
|
||||
public accessCode: string,
|
||||
public authRequestId: string,
|
||||
public decryptedUserKey: UserKey,
|
||||
public decryptedMasterKey: MasterKey,
|
||||
public decryptedMasterKeyHash: string,
|
||||
public decryptedUserKey: UserKey | null,
|
||||
public decryptedMasterKey: MasterKey | null,
|
||||
public decryptedMasterKeyHash: string | null,
|
||||
public twoFactor?: TokenTwoFactorRequest,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
|
||||
|
||||
describe("LoginViaAuthRequestCache", () => {
|
||||
let service: LoginViaAuthRequestCacheService;
|
||||
let testBed: TestBed;
|
||||
|
||||
const cacheSignal = signal<LoginViaAuthRequestView | null>(null);
|
||||
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
|
||||
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||
const cacheSetMock = jest.spyOn(cacheSignal, "set");
|
||||
|
||||
beforeEach(() => {
|
||||
getCacheSignal.mockClear();
|
||||
getFeatureFlag.mockClear();
|
||||
cacheSetMock.mockClear();
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
LoginViaAuthRequestCacheService,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature enabled", () => {
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
|
||||
cacheSignal.set({ ...buildAuthenticMockAuthView() });
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
|
||||
...buildAuthenticMockAuthView(),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the signal value", async () => {
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
|
||||
const parameters = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(
|
||||
parameters.authRequest,
|
||||
parameters.authRequestResponse,
|
||||
parameters.fingerprintPhrase,
|
||||
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||
);
|
||||
|
||||
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature disabled", () => {
|
||||
beforeEach(async () => {
|
||||
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
|
||||
getFeatureFlag.mockResolvedValue(false);
|
||||
cacheSetMock.mockClear();
|
||||
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("`getCachedCipherView` returns null", () => {
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not update the signal value", () => {
|
||||
const params = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(
|
||||
params.authRequest,
|
||||
params.authRequestResponse,
|
||||
params.fingerprintPhrase,
|
||||
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||
);
|
||||
|
||||
expect(cacheSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const buildAuthenticMockAuthView = () => {
|
||||
return {
|
||||
fingerprintPhrase: "",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
authRequest: new AuthRequest(
|
||||
"test@gmail.com",
|
||||
"deviceIdentifier",
|
||||
"publicKey",
|
||||
AuthRequestType.Unlock,
|
||||
"accessCode",
|
||||
),
|
||||
authRequestResponse: new AuthRequestResponse({}),
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
|
||||
|
||||
/**
|
||||
* This is a cache service used for the login via auth request component.
|
||||
*
|
||||
* There is sensitive information stored temporarily here. Cache will be cleared
|
||||
* after 2 minutes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginViaAuthRequestCacheService {
|
||||
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||
private configService: ConfigService = inject(ConfigService);
|
||||
|
||||
/** True when the `PM9112_DeviceApproval` flag is enabled */
|
||||
private featureEnabled: boolean = false;
|
||||
|
||||
private defaultLoginViaAuthRequestCache: WritableSignal<LoginViaAuthRequestView | null> =
|
||||
this.viewCacheService.signal<LoginViaAuthRequestView | null>({
|
||||
key: LOGIN_VIA_AUTH_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: LoginViaAuthRequestView.fromJSON,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Must be called once before interacting with the cached data, otherwise methods will be noop.
|
||||
*/
|
||||
async init() {
|
||||
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9112_DeviceApprovalPersistence,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache with the new LoginView.
|
||||
*/
|
||||
cacheLoginView(
|
||||
authRequest: AuthRequest,
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
fingerprintPhrase: string,
|
||||
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
|
||||
): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the keys get stored they should be converted to a B64 string to ensure
|
||||
// data can be properly formed when json-ified. If not done, they are not stored properly and
|
||||
// will not be parsable by the cryptography library after coming out of storage.
|
||||
this.defaultLoginViaAuthRequestCache.set({
|
||||
authRequest,
|
||||
authRequestResponse,
|
||||
fingerprintPhrase,
|
||||
privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
|
||||
publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
|
||||
} as LoginViaAuthRequestView);
|
||||
}
|
||||
|
||||
clearCacheLoginView(): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultLoginViaAuthRequestCache.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached LoginViaAuthRequestView when available.
|
||||
*/
|
||||
getCachedLoginViaAuthRequestView(): LoginViaAuthRequestView | null {
|
||||
if (!this.featureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.defaultLoginViaAuthRequestCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
export class LoginViaAuthRequestView implements View {
|
||||
authRequest: AuthRequest | undefined = undefined;
|
||||
authRequestResponse: AuthRequestResponse | undefined = undefined;
|
||||
fingerprintPhrase: string | undefined = undefined;
|
||||
privateKey: string | undefined = undefined;
|
||||
publicKey: string | undefined = undefined;
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
|
||||
return Object.assign(new LoginViaAuthRequestView(), obj);
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,16 @@ export enum FeatureFlag {
|
||||
SDKGenerators = "sdk-generators",
|
||||
|
||||
/* Vault */
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
|
||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
@@ -87,12 +91,16 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.SDKGenerators]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
|
||||
@@ -82,7 +82,7 @@ export abstract class Fido2UserInterfaceSession {
|
||||
*
|
||||
* @param params The parameters to use when asking the user to pick a credential.
|
||||
* @param abortController An abort controller that can be used to cancel/close the session.
|
||||
* @returns The ID of the cipher that contains the credentials the user picked.
|
||||
* @returns The ID of the cipher that contains the credentials the user picked. If not cipher was picked, return cipherId = undefined to to let the authenticator throw the error.
|
||||
*/
|
||||
pickCredential: (
|
||||
params: PickCredentialParams,
|
||||
|
||||
@@ -225,9 +225,10 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
|
||||
// Temporarily rolling back notifications being connected while locked
|
||||
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
// { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
|
||||
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
])(
|
||||
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
|
||||
@@ -252,7 +253,11 @@ describe("NotificationsService", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
|
||||
it.each([
|
||||
// Temporarily disabling notifications connecting while in a locked state
|
||||
// AuthenticationStatus.Locked,
|
||||
AuthenticationStatus.Unlocked,
|
||||
])(
|
||||
"connects when a user transitions from logged out to %s",
|
||||
async (newStatus: AuthenticationStatus) => {
|
||||
emitActiveUser(mockUser1);
|
||||
|
||||
@@ -123,13 +123,13 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
|
||||
);
|
||||
}
|
||||
|
||||
// This method name is a lie currently as we also have an access token
|
||||
// when locked, this is eventually where we want to be but it increases load
|
||||
// on signalR so we are rolling back until we can move the load of browser to
|
||||
// web push.
|
||||
private hasAccessToken$(userId: UserId) {
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map(
|
||||
(authStatus) =>
|
||||
authStatus === AuthenticationStatus.Locked ||
|
||||
authStatus === AuthenticationStatus.Unlocked,
|
||||
),
|
||||
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,24 @@ describe("MSecureCsvImporter.parse", () => {
|
||||
importer = new MSecureCsvImporter();
|
||||
});
|
||||
|
||||
it("should correctly parse legacy formatted cards", async () => {
|
||||
const mockCsvData =
|
||||
`aWeirdOldStyleCard|1032,Credit Card,,Security code 1234,Card Number|12|5555 4444 3333 2222,Expiration Date|11|04/0029,Name on Card|9|Obi Wan Kenobi,Security Code|9|444,`.trim();
|
||||
const result = await importer.parse(mockCsvData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ciphers.length).toBe(1);
|
||||
const cipher = result.ciphers[0];
|
||||
expect(cipher.name).toBe("aWeirdOldStyleCard");
|
||||
expect(cipher.type).toBe(CipherType.Card);
|
||||
expect(cipher.card.number).toBe("5555 4444 3333 2222");
|
||||
expect(cipher.card.expiration).toBe("04 / 2029");
|
||||
expect(cipher.card.code).toBe("444");
|
||||
expect(cipher.card.cardholderName).toBe("Obi Wan Kenobi");
|
||||
expect(cipher.notes).toBe("Security code 1234");
|
||||
expect(cipher.card.brand).toBe("");
|
||||
});
|
||||
|
||||
it("should correctly parse credit card entries as Secret Notes", async () => {
|
||||
const mockCsvData =
|
||||
`myCreditCard|155089404,Credit Card,,,Card Number|12|41111111111111111,Expiration Date|11|05/2026,Security Code|9|123,Name on Card|0|John Doe,PIN|9|1234,Issuing Bank|0|Visa,Phone Number|4|,Billing Address|0|,`.trim();
|
||||
|
||||
@@ -43,23 +43,34 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
|
||||
).split("/");
|
||||
cipher.card.expMonth = month.trim();
|
||||
cipher.card.expYear = year.trim();
|
||||
cipher.card.code = this.getValueOrDefault(this.splitValueRetainingLastPart(value[6]));
|
||||
cipher.card.cardholderName = this.getValueOrDefault(
|
||||
this.splitValueRetainingLastPart(value[7]),
|
||||
const securityCodeRegex = RegExp("^Security Code\\|\\d*\\|");
|
||||
const securityCodeEntry = value.find((entry: string) => securityCodeRegex.test(entry));
|
||||
cipher.card.code = this.getValueOrDefault(
|
||||
this.splitValueRetainingLastPart(securityCodeEntry),
|
||||
);
|
||||
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]));
|
||||
cipher.notes =
|
||||
this.getValueOrDefault(value[8].split("|")[0]) +
|
||||
": " +
|
||||
this.getValueOrDefault(this.splitValueRetainingLastPart(value[8]), "") +
|
||||
"\n" +
|
||||
this.getValueOrDefault(value[10].split("|")[0]) +
|
||||
": " +
|
||||
this.getValueOrDefault(this.splitValueRetainingLastPart(value[10]), "") +
|
||||
"\n" +
|
||||
this.getValueOrDefault(value[11].split("|")[0]) +
|
||||
": " +
|
||||
this.getValueOrDefault(this.splitValueRetainingLastPart(value[11]), "");
|
||||
|
||||
const cardNameRegex = RegExp("^Name on Card\\|\\d*\\|");
|
||||
const nameOnCardEntry = value.find((entry: string) => entry.match(cardNameRegex));
|
||||
cipher.card.cardholderName = this.getValueOrDefault(
|
||||
this.splitValueRetainingLastPart(nameOnCardEntry),
|
||||
);
|
||||
|
||||
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]), "");
|
||||
|
||||
const noteRegex = RegExp("\\|\\d*\\|");
|
||||
const rawNotes = value
|
||||
.slice(2)
|
||||
.filter((entry: string) => !this.isNullOrWhitespace(entry) && !noteRegex.test(entry));
|
||||
const noteIndexes = [8, 10, 11];
|
||||
const indexedNotes = noteIndexes
|
||||
.filter((idx) => value[idx] && noteRegex.test(value[idx]))
|
||||
.map((idx) => value[idx])
|
||||
.map((val) => {
|
||||
const key = val.split("|")[0];
|
||||
const value = this.getValueOrDefault(this.splitValueRetainingLastPart(val), "");
|
||||
return `${key}: ${value}`;
|
||||
});
|
||||
cipher.notes = [...rawNotes, ...indexedNotes].join("\n");
|
||||
} else if (value.length > 3) {
|
||||
cipher.type = CipherType.SecureNote;
|
||||
cipher.secureNote = new SecureNoteView();
|
||||
@@ -95,6 +106,6 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
|
||||
// like "Password|8|myPassword", we want to keep the "myPassword" but also ensure that if
|
||||
// the value contains any "|" it works fine
|
||||
private splitValueRetainingLastPart(value: string) {
|
||||
return value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
|
||||
return value && value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<bit-callout type="danger" title="{{ 'vaultExportDisabled' | i18n }}" *ngIf="disabledByPolicy">
|
||||
<bit-callout
|
||||
type="danger"
|
||||
title="{{ 'vaultExportDisabled' | i18n }}"
|
||||
*ngIf="disablePersonalVaultExportPolicy$ | async"
|
||||
>
|
||||
{{ "personalVaultExportPolicyInEffect" | i18n }}
|
||||
</bit-callout>
|
||||
<tools-export-scope-callout
|
||||
[organizationId]="organizationId"
|
||||
*ngIf="!disabledByPolicy"
|
||||
></tools-export-scope-callout>
|
||||
<tools-export-scope-callout [organizationId]="organizationId"></tools-export-scope-callout>
|
||||
|
||||
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
|
||||
<ng-container *ngIf="organizations$ | async as organizations">
|
||||
<bit-form-field *ngIf="organizations.length > 0">
|
||||
<bit-label>{{ "exportFrom" | i18n }}</bit-label>
|
||||
<bit-select formControlName="vaultSelector">
|
||||
<bit-option [label]="'myVault' | i18n" value="myVault" icon="bwi-user" />
|
||||
<bit-option
|
||||
[label]="'myVault' | i18n"
|
||||
value="myVault"
|
||||
icon="bwi-user"
|
||||
*ngIf="!(disablePersonalOwnershipPolicy$ | async)"
|
||||
/>
|
||||
<bit-option
|
||||
*ngFor="let o of organizations$ | async"
|
||||
[value]="o.id"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
@@ -154,6 +155,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
return this._disabledByPolicy;
|
||||
}
|
||||
|
||||
disablePersonalVaultExportPolicy$: Observable<boolean>;
|
||||
disablePersonalOwnershipPolicy$: Observable<boolean>;
|
||||
|
||||
exportForm = this.formBuilder.group({
|
||||
vaultSelector: [
|
||||
"myVault",
|
||||
@@ -201,15 +205,13 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
this.formDisabled.emit(c === "DISABLED");
|
||||
});
|
||||
|
||||
this.policyService
|
||||
.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((policyAppliesToActiveUser) => {
|
||||
this._disabledByPolicy = policyAppliesToActiveUser;
|
||||
if (this.disabledByPolicy) {
|
||||
this.exportForm.disable();
|
||||
}
|
||||
});
|
||||
// policies
|
||||
this.disablePersonalVaultExportPolicy$ = this.policyService.policyAppliesToActiveUser$(
|
||||
PolicyType.DisablePersonalVaultExport,
|
||||
);
|
||||
this.disablePersonalOwnershipPolicy$ = this.policyService.policyAppliesToActiveUser$(
|
||||
PolicyType.PersonalOwnership,
|
||||
);
|
||||
|
||||
merge(
|
||||
this.exportForm.get("format").valueChanges,
|
||||
@@ -269,13 +271,45 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
}),
|
||||
);
|
||||
|
||||
combineLatest([
|
||||
this.disablePersonalVaultExportPolicy$,
|
||||
this.disablePersonalOwnershipPolicy$,
|
||||
this.organizations$,
|
||||
])
|
||||
.pipe(
|
||||
tap(([disablePersonalVaultExport, disablePersonalOwnership, organizations]) => {
|
||||
this._disabledByPolicy = disablePersonalVaultExport;
|
||||
|
||||
// When personalOwnership is disabled and we have orgs, set the first org as the selected vault
|
||||
if (disablePersonalOwnership && organizations.length > 0) {
|
||||
this.exportForm.enable();
|
||||
this.exportForm.controls.vaultSelector.setValue(organizations[0].id);
|
||||
}
|
||||
|
||||
// When personalOwnership is disabled and we have no orgs, disable the form
|
||||
if (disablePersonalOwnership && organizations.length === 0) {
|
||||
this.exportForm.disable();
|
||||
}
|
||||
|
||||
// When personalVaultExport is disabled, disable the form
|
||||
if (disablePersonalVaultExport) {
|
||||
this.exportForm.disable();
|
||||
}
|
||||
|
||||
// When neither policy is enabled, enable the form and set the default vault to "myVault"
|
||||
if (!disablePersonalVaultExport && !disablePersonalOwnership) {
|
||||
this.exportForm.controls.vaultSelector.setValue("myVault");
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.exportForm.controls.vaultSelector.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
this.organizationId = value != "myVault" ? value : undefined;
|
||||
});
|
||||
|
||||
this.exportForm.controls.vaultSelector.setValue("myVault");
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -286,6 +320,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
get encryptedFormat() {
|
||||
|
||||
@@ -25,6 +25,11 @@ export type OptionalInitialValues = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
cardholderName?: string;
|
||||
number?: string;
|
||||
expMonth?: string;
|
||||
expYear?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,8 @@ describe("CardDetailsSectionComponent", () => {
|
||||
cardView.cardholderName = "Ron Burgundy";
|
||||
cardView.number = "4242 4242 4242 4242";
|
||||
cardView.brand = "Visa";
|
||||
cardView.expMonth = "";
|
||||
cardView.code = "";
|
||||
|
||||
expect(patchCipherSpy).toHaveBeenCalled();
|
||||
const patchFn = patchCipherSpy.mock.lastCall[0];
|
||||
@@ -79,6 +81,10 @@ describe("CardDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
const cardView = new CardView();
|
||||
cardView.cardholderName = "";
|
||||
cardView.number = "";
|
||||
cardView.expMonth = "";
|
||||
cardView.code = "";
|
||||
cardView.expYear = "2022";
|
||||
|
||||
expect(patchCipherSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -97,6 +97,10 @@ export class CardDetailsSectionComponent implements OnInit {
|
||||
|
||||
EventType = EventType;
|
||||
|
||||
get initialValues() {
|
||||
return this.cipherFormContainer.config.initialValues;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
@@ -139,7 +143,9 @@ export class CardDetailsSectionComponent implements OnInit {
|
||||
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||
|
||||
if (prefillCipher) {
|
||||
this.setInitialValues(prefillCipher);
|
||||
this.initFromExistingCipher(prefillCipher.card);
|
||||
} else {
|
||||
this.initNewCipher();
|
||||
}
|
||||
|
||||
if (this.disabled) {
|
||||
@@ -147,6 +153,26 @@ export class CardDetailsSectionComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private initFromExistingCipher(existingCard: CardView) {
|
||||
this.cardDetailsForm.patchValue({
|
||||
cardholderName: this.initialValues?.cardholderName ?? existingCard.cardholderName,
|
||||
number: this.initialValues?.number ?? existingCard.number,
|
||||
expMonth: this.initialValues?.expMonth ?? existingCard.expMonth,
|
||||
expYear: this.initialValues?.expYear ?? existingCard.expYear,
|
||||
code: this.initialValues?.code ?? existingCard.code,
|
||||
});
|
||||
}
|
||||
|
||||
private initNewCipher() {
|
||||
this.cardDetailsForm.patchValue({
|
||||
cardholderName: this.initialValues?.cardholderName || "",
|
||||
number: this.initialValues?.number || "",
|
||||
expMonth: this.initialValues?.expMonth || "",
|
||||
expYear: this.initialValues?.expYear || "",
|
||||
code: this.initialValues?.code || "",
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the section heading based on the card brand */
|
||||
getSectionHeading(): string {
|
||||
const { brand } = this.cardDetailsForm.value;
|
||||
|
||||
@@ -15,7 +15,8 @@ import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
@@ -87,6 +88,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private changeLoginPasswordService: ChangeLoginPasswordService,
|
||||
private configService: ConfigService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
async ngOnChanges() {
|
||||
@@ -152,7 +154,12 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
|
||||
const userId = await firstValueFrom(this.activeUserId$);
|
||||
|
||||
if (this.cipher.edit && this.cipher.viewPassword) {
|
||||
// Show Tasks for Manage and Edit permissions
|
||||
// Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions
|
||||
const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId];
|
||||
|
||||
if (cipherServiceCipher?.edit && cipherServiceCipher?.viewPassword) {
|
||||
await this.checkPendingChangePasswordTasks(userId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user