mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 11:54:02 +00:00
Merge branch 'main' into dirt/pm-20630/my-items-in-report
This commit is contained in:
@@ -20,11 +20,13 @@ import {
|
||||
import {
|
||||
DefaultLoginComponentService,
|
||||
DefaultLoginDecryptionOptionsService,
|
||||
DefaultNewDeviceVerificationComponentService,
|
||||
DefaultRegistrationFinishService,
|
||||
DefaultTwoFactorAuthComponentService,
|
||||
DefaultTwoFactorAuthWebAuthnComponentService,
|
||||
LoginComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
NewDeviceVerificationComponentService,
|
||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||
TwoFactorAuthComponentService,
|
||||
TwoFactorAuthWebAuthnComponentService,
|
||||
@@ -102,6 +104,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
|
||||
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
|
||||
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
|
||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
|
||||
@@ -1586,6 +1589,11 @@ const safeProviders: SafeProvider[] = [
|
||||
MessageListener,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SendTokenService,
|
||||
useClass: DefaultSendTokenService,
|
||||
deps: [GlobalStateProvider, SdkService, SendPasswordService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
@@ -1646,6 +1654,11 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NewDeviceVerificationComponentService,
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -59,6 +59,8 @@ export * from "./two-factor-auth";
|
||||
|
||||
// device verification
|
||||
export * from "./new-device-verification/new-device-verification.component";
|
||||
export * from "./new-device-verification/new-device-verification-component.service";
|
||||
export * from "./new-device-verification/default-new-device-verification-component.service";
|
||||
|
||||
// validators
|
||||
export * from "./validators/compare-inputs.validator";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DefaultNewDeviceVerificationComponentService } from "./default-new-device-verification-component.service";
|
||||
|
||||
describe("DefaultNewDeviceVerificationComponentService", () => {
|
||||
let sut: DefaultNewDeviceVerificationComponentService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new DefaultNewDeviceVerificationComponentService();
|
||||
});
|
||||
|
||||
it("should instantiate the service", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("showBackButton()", () => {
|
||||
it("should return true", () => {
|
||||
const result = sut.showBackButton();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
|
||||
|
||||
export class DefaultNewDeviceVerificationComponentService
|
||||
implements NewDeviceVerificationComponentService
|
||||
{
|
||||
showBackButton() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export abstract class NewDeviceVerificationComponentService {
|
||||
/**
|
||||
* States whether component should show a back button. Can be overridden by client-specific component services.
|
||||
* - Default = `true`
|
||||
* - Extension = `false` (because Extension shows a back button in the header instead)
|
||||
*/
|
||||
abstract showBackButton: () => boolean;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
{{ "resendCode" | i18n }}
|
||||
</button>
|
||||
|
||||
<div class="tw-flex tw-mt-4">
|
||||
<div class="tw-grid tw-gap-3 tw-mt-4">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
@@ -33,5 +33,13 @@
|
||||
>
|
||||
{{ "continueLoggingIn" | i18n }}
|
||||
</button>
|
||||
|
||||
@if (showBackButton) {
|
||||
<div class="tw-text-center">{{ "or" | i18n }}</div>
|
||||
|
||||
<button type="button" bitButton block buttonType="secondary" (click)="goBack()">
|
||||
{{ "back" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { CommonModule, Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
@@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
@@ -26,6 +25,8 @@ import {
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
|
||||
|
||||
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
|
||||
|
||||
/**
|
||||
* Component for verifying a new device via a one-time password (OTP).
|
||||
*/
|
||||
@@ -57,6 +58,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
protected disableRequestOTP = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
|
||||
protected showBackButton = true;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -66,12 +68,15 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private newDeviceVerificationComponentService: NewDeviceVerificationComponentService,
|
||||
private location: Location,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showBackButton = this.newDeviceVerificationComponentService.showBackButton();
|
||||
|
||||
// Redirect to timeout route if session expires
|
||||
this.loginStrategyService.authenticationSessionTimeout$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -179,4 +184,8 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
codeControl.markAsTouched();
|
||||
}
|
||||
};
|
||||
|
||||
protected goBack() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
1
libs/common/src/auth/send-access/abstractions/index.ts
Normal file
1
libs/common/src/auth/send-access/abstractions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./send-token.service";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SendAccessToken } from "../models/send-access-token";
|
||||
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
|
||||
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
|
||||
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
|
||||
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
|
||||
|
||||
/**
|
||||
* Service to manage send access tokens.
|
||||
*/
|
||||
export abstract class SendTokenService {
|
||||
/**
|
||||
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
|
||||
* If the access token is found in session storage and is not expired, then it returns the token.
|
||||
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
|
||||
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
|
||||
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
|
||||
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.
|
||||
* Any submissions of credentials will be handled by the getSendAccessToken$ method.
|
||||
* @param sendId The ID of the send to retrieve the access token for.
|
||||
* @returns An observable that emits a SendAccessToken if successful, or a TryGetSendAccessTokenError if not.
|
||||
*/
|
||||
abstract tryGetSendAccessToken$: (
|
||||
sendId: string,
|
||||
) => Observable<SendAccessToken | TryGetSendAccessTokenError>;
|
||||
|
||||
/**
|
||||
* Retrieves a SendAccessToken for the given sendId using the provided credentials.
|
||||
* If the access token is successfully retrieved from the server, it stores the token in session storage and returns it.
|
||||
* If the access token cannot be granted due to invalid credentials, it returns a {@link GetSendAccessTokenError}.
|
||||
* @param sendId The ID of the send to retrieve the access token for.
|
||||
* @param sendAccessCredentials The credentials to use for accessing the send.
|
||||
* @returns An observable that emits a SendAccessToken if successful, or a GetSendAccessTokenError if not.
|
||||
*/
|
||||
abstract getSendAccessToken$: (
|
||||
sendId: string,
|
||||
sendAccessCredentials: SendAccessDomainCredentials,
|
||||
) => Observable<SendAccessToken | GetSendAccessTokenError>;
|
||||
|
||||
/**
|
||||
* Hashes a password for send access which is required to create a {@link SendAccessTokenRequest}
|
||||
* (more specifically, to create a {@link SendAccessDomainCredentials} for sends that require a password)
|
||||
* @param password The raw password string to hash.
|
||||
* @param keyMaterialUrlB64 The base64 URL encoded key material string.
|
||||
* @returns A promise that resolves to the hashed password as a SendHashedPasswordB64.
|
||||
*/
|
||||
abstract hashSendPassword: (
|
||||
password: string,
|
||||
keyMaterialUrlB64: string,
|
||||
) => Promise<SendHashedPasswordB64>;
|
||||
|
||||
/**
|
||||
* Clears a send access token from storage.
|
||||
*/
|
||||
abstract invalidateSendAccessToken: (sendId: string) => Promise<void>;
|
||||
}
|
||||
4
libs/common/src/auth/send-access/index.ts
Normal file
4
libs/common/src/auth/send-access/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
export * from "./types";
|
||||
1
libs/common/src/auth/send-access/models/index.ts
Normal file
1
libs/common/src/auth/send-access/models/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./send-access-token";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SendAccessToken } from "./send-access-token";
|
||||
|
||||
describe("SendAccessToken", () => {
|
||||
const sendId = "sendId";
|
||||
|
||||
const NOW = 1_000_000; // fixed timestamp for predictable results
|
||||
|
||||
const expiresAt: number = NOW + 1000 * 60 * 5; // 5 minutes from now
|
||||
|
||||
const expiredExpiresAt: number = NOW - 1000 * 60 * 5; // 5 minutes ago
|
||||
|
||||
let nowSpy: jest.SpyInstance<number, []>;
|
||||
|
||||
beforeAll(() => {
|
||||
nowSpy = jest.spyOn(Date, "now");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure every test starts from the same fixed time
|
||||
nowSpy.mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should create a valid, unexpired token", () => {
|
||||
const token = new SendAccessToken(sendId, expiresAt);
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.isExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be expired after the expiration time", () => {
|
||||
const token = new SendAccessToken(sendId, expiredExpiresAt);
|
||||
expect(token.isExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be considered expired if within 5 seconds of expiration", () => {
|
||||
const token = new SendAccessToken(sendId, expiresAt);
|
||||
nowSpy.mockReturnValue(expiresAt - 4_000); // 4 seconds before expiry
|
||||
expect(token.isExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return the correct time until expiry in seconds", () => {
|
||||
const token = new SendAccessToken(sendId, expiresAt);
|
||||
expect(token.timeUntilExpirySeconds()).toBe(300); // 5 minutes
|
||||
});
|
||||
|
||||
it("should return 0 if the token is expired", () => {
|
||||
const token = new SendAccessToken(sendId, expiredExpiresAt);
|
||||
expect(token.timeUntilExpirySeconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("should create a token from JSON", () => {
|
||||
const json = {
|
||||
token: sendId,
|
||||
expiresAt: expiresAt,
|
||||
};
|
||||
const token = SendAccessToken.fromJson(json);
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.isExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it("should create a token from SendAccessTokenResponse", () => {
|
||||
const response = {
|
||||
token: sendId,
|
||||
expiresAt: expiresAt,
|
||||
} as SendAccessTokenResponse;
|
||||
const token = SendAccessToken.fromSendAccessTokenResponse(response);
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.isExpired()).toBe(false);
|
||||
});
|
||||
});
|
||||
46
libs/common/src/auth/send-access/models/send-access-token.ts
Normal file
46
libs/common/src/auth/send-access/models/send-access-token.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
export class SendAccessToken {
|
||||
constructor(
|
||||
/**
|
||||
* The access token string
|
||||
*/
|
||||
readonly token: string,
|
||||
/**
|
||||
* The time (in milliseconds since the epoch) when the token expires
|
||||
*/
|
||||
readonly expiresAt: number,
|
||||
) {}
|
||||
|
||||
/** Returns whether the send access token is expired or not
|
||||
* Has a 5 second threshold to avoid race conditions with the token
|
||||
* expiring in flight
|
||||
*/
|
||||
isExpired(threshold: number = 5_000): boolean {
|
||||
return Date.now() >= this.expiresAt - threshold;
|
||||
}
|
||||
|
||||
/** Returns how many full seconds remain until expiry. Returns 0 if expired. */
|
||||
timeUntilExpirySeconds(): number {
|
||||
return Math.max(0, Math.floor((this.expiresAt - Date.now()) / 1_000));
|
||||
}
|
||||
|
||||
static fromJson(parsedJson: Jsonify<SendAccessToken>): SendAccessToken {
|
||||
return new SendAccessToken(parsedJson.token, parsedJson.expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SendAccessToken from a SendAccessTokenResponse.
|
||||
* @param sendAccessTokenResponse The SDK response object containing the token and expiry information.
|
||||
* @returns A new instance of SendAccessToken.
|
||||
* note: we need to convert from the SDK response type to our internal type so that we can
|
||||
* be sure it will serialize/deserialize correctly in state provider.
|
||||
*/
|
||||
static fromSendAccessTokenResponse(
|
||||
sendAccessTokenResponse: SendAccessTokenResponse,
|
||||
): SendAccessToken {
|
||||
return new SendAccessToken(sendAccessTokenResponse.token, sendAccessTokenResponse.expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
SendAccessTokenApiErrorResponse,
|
||||
SendAccessTokenError,
|
||||
SendAccessTokenInvalidGrantError,
|
||||
SendAccessTokenInvalidRequestError,
|
||||
SendAccessTokenResponse,
|
||||
UnexpectedIdentityError,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
|
||||
|
||||
import {
|
||||
SendHashedPassword,
|
||||
SendPasswordKeyMaterial,
|
||||
SendPasswordService,
|
||||
} from "../../../key-management/sends";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { MockSdkService } from "../../../platform/spec/mock-sdk.service";
|
||||
import { SendAccessToken } from "../models/send-access-token";
|
||||
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
|
||||
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
|
||||
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
|
||||
import { SendOtp } from "../types/send-otp.type";
|
||||
|
||||
import { DefaultSendTokenService } from "./default-send-token.service";
|
||||
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
|
||||
|
||||
describe("SendTokenService", () => {
|
||||
let service: DefaultSendTokenService;
|
||||
|
||||
// Deps
|
||||
let sdkService: MockSdkService;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
let sendPasswordService: MockProxy<SendPasswordService>;
|
||||
|
||||
beforeEach(() => {
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
sdkService = new MockSdkService();
|
||||
sendPasswordService = mock<SendPasswordService>();
|
||||
|
||||
service = new DefaultSendTokenService(globalStateProvider, sdkService, sendPasswordService);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("Send access token retrieval tests", () => {
|
||||
let sendAccessTokenDictGlobalState: FakeGlobalState<Record<string, SendAccessToken>>;
|
||||
|
||||
let sendAccessTokenResponse: SendAccessTokenResponse;
|
||||
|
||||
let sendId: string;
|
||||
let sendAccessToken: SendAccessToken;
|
||||
let token: string;
|
||||
let tokenExpiresAt: number;
|
||||
|
||||
const EXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "expected_server";
|
||||
const UNEXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "unexpected_server";
|
||||
|
||||
const INVALID_REQUEST_CODES: SendAccessTokenInvalidRequestError[] = [
|
||||
"send_id_required",
|
||||
"password_hash_b64_required",
|
||||
"email_required",
|
||||
"email_and_otp_required_otp_sent",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
|
||||
"send_id_invalid",
|
||||
"password_hash_b64_invalid",
|
||||
"email_invalid",
|
||||
"otp_invalid",
|
||||
"otp_generation_failed",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const CREDS = [
|
||||
{ kind: "password", passwordHashB64: "h4sh" as SendHashedPasswordB64 },
|
||||
{ kind: "email", email: "u@example.com" },
|
||||
{ kind: "email_otp", email: "u@example.com", otp: "123456" as SendOtp },
|
||||
] as const satisfies readonly SendAccessDomainCredentials[];
|
||||
|
||||
type SendAccessTokenApiErrorResponseErrorCode = SendAccessTokenApiErrorResponse["error"];
|
||||
|
||||
type SimpleErrorType = Exclude<
|
||||
SendAccessTokenApiErrorResponseErrorCode,
|
||||
"invalid_request" | "invalid_grant"
|
||||
>;
|
||||
|
||||
// Extract out simple error types which don't have complex send_access_error_types to handle.
|
||||
const SIMPLE_ERROR_TYPES = [
|
||||
"invalid_client",
|
||||
"unauthorized_client",
|
||||
"unsupported_grant_type",
|
||||
"invalid_scope",
|
||||
"invalid_target",
|
||||
] as const satisfies readonly SimpleErrorType[];
|
||||
|
||||
beforeEach(() => {
|
||||
sendId = "sendId";
|
||||
token = "sendAccessToken";
|
||||
tokenExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes from now
|
||||
|
||||
sendAccessTokenResponse = {
|
||||
token: token,
|
||||
expiresAt: tokenExpiresAt,
|
||||
};
|
||||
|
||||
sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(sendAccessTokenResponse);
|
||||
|
||||
sendAccessTokenDictGlobalState = globalStateProvider.getFake(SEND_ACCESS_TOKEN_DICT);
|
||||
// Ensure the state is empty before each test
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({});
|
||||
});
|
||||
|
||||
describe("tryGetSendAccessToken$", () => {
|
||||
it("returns the send access token from session storage when token exists and isn't expired", async () => {
|
||||
// Arrange
|
||||
// Store the send access token in the global state
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(sendAccessToken);
|
||||
});
|
||||
|
||||
it("returns expired error and clears token from storage when token is expired", async () => {
|
||||
// Arrange
|
||||
const oldDate = new Date("2025-01-01");
|
||||
const expiredSendAccessToken = new SendAccessToken(token, oldDate.getTime());
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: expiredSendAccessToken });
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).not.toBeInstanceOf(SendAccessToken);
|
||||
expect(result).toStrictEqual({ kind: "expired" });
|
||||
|
||||
// assert that we removed the expired token from storage.
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
|
||||
});
|
||||
|
||||
it("calls to get a new token if none is found in storage and stores the retrieved token in session storage", async () => {
|
||||
// Arrange
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBeInstanceOf(SendAccessToken);
|
||||
expect(result).toEqual(sendAccessToken);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
});
|
||||
|
||||
describe("handles expected invalid_request scenarios appropriately", () => {
|
||||
it.each(INVALID_REQUEST_CODES)(
|
||||
"surfaces %s as an expected invalid_request error",
|
||||
async (code) => {
|
||||
// Arrange
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_request",
|
||||
error_description: code,
|
||||
send_access_error_type: code,
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: EXPECTED_SERVER_KIND,
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("handles bare expected invalid_request scenario appropriately", async () => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_request",
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: EXPECTED_SERVER_KIND,
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(SIMPLE_ERROR_TYPES)("handles expected %s error appropriately", async (errorType) => {
|
||||
const api: SendAccessTokenApiErrorResponse = {
|
||||
error: errorType,
|
||||
error_description: `The ${errorType} error occurred`,
|
||||
};
|
||||
mockSdkRejectWith({ kind: "expected", data: api });
|
||||
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
|
||||
});
|
||||
|
||||
it.each(SIMPLE_ERROR_TYPES)(
|
||||
"handles expected %s bare error appropriately",
|
||||
async (errorType) => {
|
||||
const api: SendAccessTokenApiErrorResponse = { error: errorType };
|
||||
mockSdkRejectWith({ kind: "expected", data: api });
|
||||
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
|
||||
},
|
||||
);
|
||||
|
||||
describe("handles expected invalid_grant scenarios appropriately", () => {
|
||||
it.each(INVALID_GRANT_CODES)(
|
||||
"surfaces %s as an expected invalid_grant error",
|
||||
async (code) => {
|
||||
// Arrange
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_grant",
|
||||
error_description: code,
|
||||
send_access_error_type: code,
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: EXPECTED_SERVER_KIND,
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("handles bare expected invalid_grant scenario appropriately", async () => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_grant",
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: EXPECTED_SERVER_KIND,
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces unexpected errors as unexpected_server error", async () => {
|
||||
// Arrange
|
||||
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
|
||||
|
||||
mockSdkRejectWith({
|
||||
kind: "unexpected",
|
||||
data: unexpectedIdentityError,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: UNEXPECTED_SERVER_KIND,
|
||||
error: unexpectedIdentityError,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an unknown error as an unknown error", async () => {
|
||||
// Arrange
|
||||
const unknownError = "unknown error occurred";
|
||||
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockRejectedValue(new Error(unknownError));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: "unknown",
|
||||
error: unknownError,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSendAccessTokenFromStorage", () => {
|
||||
it("returns undefined if no token is found in storage", async () => {
|
||||
const result = await (service as any).getSendAccessTokenFromStorage("nonexistentSendId");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the token if found in storage", async () => {
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
|
||||
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
|
||||
expect(result).toEqual(sendAccessToken);
|
||||
});
|
||||
|
||||
it("returns undefined if the global state isn't initialized yet", async () => {
|
||||
sendAccessTokenDictGlobalState.stateSubject.next(null);
|
||||
|
||||
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSendAccessTokenInStorage", () => {
|
||||
it("stores the token in storage", async () => {
|
||||
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
});
|
||||
|
||||
it("initializes the dictionary if it isn't already", async () => {
|
||||
sendAccessTokenDictGlobalState.stateSubject.next(null);
|
||||
|
||||
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
});
|
||||
|
||||
it("merges with existing tokens in storage", async () => {
|
||||
const anotherSendId = "anotherSendId";
|
||||
const anotherSendAccessToken = new SendAccessToken(
|
||||
"anotherToken",
|
||||
Date.now() + 1000 * 60,
|
||||
);
|
||||
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({
|
||||
[anotherSendId]: anotherSendAccessToken,
|
||||
});
|
||||
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
expect(sendAccessTokenDict).toHaveProperty(anotherSendId, anotherSendAccessToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSendAccessToken$", () => {
|
||||
it("returns a send access token for a password protected send when given valid password credentials", async () => {
|
||||
// Arrange
|
||||
const sendPasswordCredentials: SendAccessDomainCredentials = {
|
||||
kind: "password",
|
||||
passwordHashB64: "testPassword" as SendHashedPasswordB64,
|
||||
};
|
||||
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(
|
||||
service.getSendAccessToken$(sendId, sendPasswordCredentials),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(sendAccessToken);
|
||||
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
});
|
||||
|
||||
// Note: we deliberately aren't testing the "success" scenario of passing
|
||||
// just SendEmailCredentials as that will never return a send access token on it's own.
|
||||
|
||||
it("returns a send access token for a email + otp protected send when given valid email and otp", async () => {
|
||||
// Arrange
|
||||
const sendEmailOtpCredentials: SendAccessDomainCredentials = {
|
||||
kind: "email_otp",
|
||||
email: "test@example.com",
|
||||
otp: "123456" as SendOtp,
|
||||
};
|
||||
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(
|
||||
service.getSendAccessToken$(sendId, sendEmailOtpCredentials),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(sendAccessToken);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
|
||||
});
|
||||
|
||||
describe.each(CREDS.map((c) => [c.kind, c] as const))(
|
||||
"scenarios with %s credentials",
|
||||
(_label, creds) => {
|
||||
it.each(INVALID_REQUEST_CODES)(
|
||||
"handles expected invalid_request.%s scenario appropriately",
|
||||
async (code) => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_request",
|
||||
error_description: code,
|
||||
send_access_error_type: code,
|
||||
};
|
||||
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "expected_server",
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("handles expected invalid_request scenario appropriately", async () => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_request",
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: "expected_server",
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(INVALID_GRANT_CODES)(
|
||||
"handles expected invalid_grant.%s scenario appropriately",
|
||||
async (code) => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_grant",
|
||||
error_description: code,
|
||||
send_access_error_type: code,
|
||||
};
|
||||
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "expected_server",
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("handles expected invalid_grant scenario appropriately", async () => {
|
||||
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
|
||||
error: "invalid_grant",
|
||||
};
|
||||
mockSdkRejectWith({
|
||||
kind: "expected",
|
||||
data: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: "expected_server",
|
||||
error: sendAccessTokenApiErrorResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(SIMPLE_ERROR_TYPES)(
|
||||
"handles expected %s error appropriately",
|
||||
async (errorType) => {
|
||||
const api: SendAccessTokenApiErrorResponse = {
|
||||
error: errorType,
|
||||
error_description: `The ${errorType} error occurred`,
|
||||
};
|
||||
mockSdkRejectWith({ kind: "expected", data: api });
|
||||
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SIMPLE_ERROR_TYPES)(
|
||||
"handles expected %s bare error appropriately",
|
||||
async (errorType) => {
|
||||
const api: SendAccessTokenApiErrorResponse = { error: errorType };
|
||||
mockSdkRejectWith({ kind: "expected", data: api });
|
||||
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces unexpected errors as unexpected_server error", async () => {
|
||||
// Arrange
|
||||
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
|
||||
|
||||
mockSdkRejectWith({
|
||||
kind: "unexpected",
|
||||
data: unexpectedIdentityError,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: UNEXPECTED_SERVER_KIND,
|
||||
error: unexpectedIdentityError,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an unknown error as an unknown error", async () => {
|
||||
// Arrange
|
||||
const unknownError = "unknown error occurred";
|
||||
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockRejectedValue(new Error(unknownError));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
kind: "unknown",
|
||||
error: unknownError,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("errors if passwordHashB64 is missing for password credentials", async () => {
|
||||
const creds: SendAccessDomainCredentials = {
|
||||
kind: "password",
|
||||
passwordHashB64: "" as SendHashedPasswordB64,
|
||||
};
|
||||
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
|
||||
"passwordHashB64 must be provided for password credentials.",
|
||||
);
|
||||
});
|
||||
|
||||
it("errors if email is missing for email credentials", async () => {
|
||||
const creds: SendAccessDomainCredentials = {
|
||||
kind: "email",
|
||||
email: "",
|
||||
};
|
||||
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
|
||||
"email must be provided for email credentials.",
|
||||
);
|
||||
});
|
||||
|
||||
it("errors if email or otp is missing for email_otp credentials", async () => {
|
||||
const creds: SendAccessDomainCredentials = {
|
||||
kind: "email_otp",
|
||||
email: "",
|
||||
otp: "" as SendOtp,
|
||||
};
|
||||
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
|
||||
"email and otp must be provided for email_otp credentials.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateSendAccessToken", () => {
|
||||
it("removes a send access token from storage", async () => {
|
||||
// Arrange
|
||||
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
|
||||
|
||||
// Act
|
||||
await service.invalidateSendAccessToken(sendId);
|
||||
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
|
||||
|
||||
// Assert
|
||||
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashSendPassword", () => {
|
||||
test.each(["", null, undefined])("rejects if password is %p", async (pwd) => {
|
||||
await expect(service.hashSendPassword(pwd as any, "keyMaterialUrlB64")).rejects.toThrow(
|
||||
"Password must be provided.",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["", null, undefined])(
|
||||
"rejects if keyMaterialUrlB64 is %p",
|
||||
async (keyMaterialUrlB64) => {
|
||||
await expect(
|
||||
service.hashSendPassword("password", keyMaterialUrlB64 as any),
|
||||
).rejects.toThrow("KeyMaterialUrlB64 must be provided.");
|
||||
},
|
||||
);
|
||||
|
||||
it("correctly hashes the password", async () => {
|
||||
// Arrange
|
||||
const password = "testPassword";
|
||||
const keyMaterialUrlB64 = "testKeyMaterialUrlB64";
|
||||
const keyMaterialArray = new Uint8Array([1, 2, 3]) as SendPasswordKeyMaterial;
|
||||
const hashedPasswordArray = new Uint8Array([4, 5, 6]) as SendHashedPassword;
|
||||
const sendHashedPasswordB64 = "hashedPasswordB64" as SendHashedPasswordB64;
|
||||
|
||||
const utilsFromUrlB64ToArraySpy = jest
|
||||
.spyOn(Utils, "fromUrlB64ToArray")
|
||||
.mockReturnValue(keyMaterialArray);
|
||||
|
||||
sendPasswordService.hashPassword.mockResolvedValue(hashedPasswordArray);
|
||||
|
||||
const utilsFromBufferToB64Spy = jest
|
||||
.spyOn(Utils, "fromBufferToB64")
|
||||
.mockReturnValue(sendHashedPasswordB64);
|
||||
|
||||
// Act
|
||||
const result = await service.hashSendPassword(password, keyMaterialUrlB64);
|
||||
|
||||
// Assert
|
||||
expect(sendPasswordService.hashPassword).toHaveBeenCalledWith(password, keyMaterialArray);
|
||||
expect(utilsFromUrlB64ToArraySpy).toHaveBeenCalledWith(keyMaterialUrlB64);
|
||||
expect(utilsFromBufferToB64Spy).toHaveBeenCalledWith(hashedPasswordArray);
|
||||
expect(result).toBe(sendHashedPasswordB64);
|
||||
});
|
||||
});
|
||||
|
||||
function mockSdkRejectWith(sendAccessTokenError: SendAccessTokenError) {
|
||||
sdkService.client.auth
|
||||
.mockDeep()
|
||||
.send_access.mockDeep()
|
||||
.request_send_access_token.mockRejectedValue(sendAccessTokenError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Observable, defer, firstValueFrom, from } from "rxjs";
|
||||
|
||||
import {
|
||||
BitwardenClient,
|
||||
SendAccessCredentials,
|
||||
SendAccessTokenError,
|
||||
SendAccessTokenRequest,
|
||||
SendAccessTokenResponse,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
import { GlobalState, GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
import { SendPasswordService } from "../../../key-management/sends/abstractions/send-password.service";
|
||||
import {
|
||||
SendHashedPassword,
|
||||
SendPasswordKeyMaterial,
|
||||
} from "../../../key-management/sends/types/send-hashed-password.type";
|
||||
import { SdkService } from "../../../platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SendTokenService as SendTokenServiceAbstraction } from "../abstractions/send-token.service";
|
||||
import { SendAccessToken } from "../models/send-access-token";
|
||||
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
|
||||
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
|
||||
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
|
||||
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
|
||||
|
||||
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
|
||||
|
||||
export class DefaultSendTokenService implements SendTokenServiceAbstraction {
|
||||
private sendAccessTokenDictGlobalState: GlobalState<Record<string, SendAccessToken>> | undefined;
|
||||
|
||||
constructor(
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private sdkService: SdkService,
|
||||
private sendPasswordService: SendPasswordService,
|
||||
) {
|
||||
this.initializeState();
|
||||
}
|
||||
|
||||
private initializeState(): void {
|
||||
this.sendAccessTokenDictGlobalState = this.globalStateProvider.get(SEND_ACCESS_TOKEN_DICT);
|
||||
}
|
||||
|
||||
tryGetSendAccessToken$(sendId: string): Observable<SendAccessToken | TryGetSendAccessTokenError> {
|
||||
// Defer the execution to ensure that a cold observable is returned.
|
||||
return defer(() => from(this._tryGetSendAccessToken(sendId)));
|
||||
}
|
||||
|
||||
private async _tryGetSendAccessToken(
|
||||
sendId: string,
|
||||
): Promise<SendAccessToken | TryGetSendAccessTokenError> {
|
||||
// Validate the sendId is a non-empty string.
|
||||
this.validateSendId(sendId);
|
||||
|
||||
// Check in storage for the access token for the given sendId.
|
||||
const sendAccessTokenFromStorage = await this.getSendAccessTokenFromStorage(sendId);
|
||||
|
||||
if (sendAccessTokenFromStorage != null) {
|
||||
// If it is expired, we clear the token from storage and return the expired error
|
||||
if (sendAccessTokenFromStorage.isExpired()) {
|
||||
await this.clearSendAccessTokenFromStorage(sendId);
|
||||
return { kind: "expired" };
|
||||
} else {
|
||||
// If it is not expired, we return it
|
||||
return sendAccessTokenFromStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a token in storage, we can try to request a new token from the server.
|
||||
const request: SendAccessTokenRequest = {
|
||||
sendId: sendId,
|
||||
};
|
||||
|
||||
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
|
||||
|
||||
try {
|
||||
const result: SendAccessTokenResponse = await anonSdkClient
|
||||
.auth()
|
||||
.send_access()
|
||||
.request_send_access_token(request);
|
||||
|
||||
// Convert from SDK shape to SendAccessToken so it can be serialized into & out of state provider
|
||||
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
|
||||
|
||||
// If we get a token back, we need to store it in the global state.
|
||||
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
|
||||
|
||||
return sendAccessToken;
|
||||
} catch (error: unknown) {
|
||||
return this.normalizeSendAccessTokenError(error);
|
||||
}
|
||||
}
|
||||
|
||||
getSendAccessToken$(
|
||||
sendId: string,
|
||||
sendCredentials: SendAccessDomainCredentials,
|
||||
): Observable<SendAccessToken | GetSendAccessTokenError> {
|
||||
// Defer the execution to ensure that a cold observable is returned.
|
||||
return defer(() => from(this._getSendAccessToken(sendId, sendCredentials)));
|
||||
}
|
||||
|
||||
private async _getSendAccessToken(
|
||||
sendId: string,
|
||||
sendAccessCredentials: SendAccessDomainCredentials,
|
||||
): Promise<SendAccessToken | GetSendAccessTokenError> {
|
||||
// Validate inputs to account for non-strict TS call sites.
|
||||
this.validateCredentialsRequest(sendId, sendAccessCredentials);
|
||||
|
||||
// Convert inputs to SDK request shape
|
||||
const request: SendAccessTokenRequest = {
|
||||
sendId: sendId,
|
||||
sendAccessCredentials: this.convertDomainCredentialsToSdkCredentials(sendAccessCredentials),
|
||||
};
|
||||
|
||||
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
|
||||
|
||||
try {
|
||||
const result: SendAccessTokenResponse = await anonSdkClient
|
||||
.auth()
|
||||
.send_access()
|
||||
.request_send_access_token(request);
|
||||
|
||||
// Convert from SDK interface to SendAccessToken class so it can be serialized into & out of state provider
|
||||
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
|
||||
|
||||
// Any time we get a token from the server, we need to store it in the global state.
|
||||
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
|
||||
|
||||
return sendAccessToken;
|
||||
} catch (error: unknown) {
|
||||
return this.normalizeSendAccessTokenError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateSendAccessToken(sendId: string): Promise<void> {
|
||||
await this.clearSendAccessTokenFromStorage(sendId);
|
||||
}
|
||||
|
||||
async hashSendPassword(
|
||||
password: string,
|
||||
keyMaterialUrlB64: string,
|
||||
): Promise<SendHashedPasswordB64> {
|
||||
// Validate the password and key material
|
||||
if (password == null || password.trim() === "") {
|
||||
throw new Error("Password must be provided.");
|
||||
}
|
||||
if (keyMaterialUrlB64 == null || keyMaterialUrlB64.trim() === "") {
|
||||
throw new Error("KeyMaterialUrlB64 must be provided.");
|
||||
}
|
||||
|
||||
// Convert the base64 URL encoded key material to a Uint8Array
|
||||
const keyMaterialUrlB64Array = Utils.fromUrlB64ToArray(
|
||||
keyMaterialUrlB64,
|
||||
) as SendPasswordKeyMaterial;
|
||||
|
||||
const sendHashedPasswordArray: SendHashedPassword = await this.sendPasswordService.hashPassword(
|
||||
password,
|
||||
keyMaterialUrlB64Array,
|
||||
);
|
||||
|
||||
// Convert the Uint8Array to a base64 encoded string which is required
|
||||
// for the server to be able to compare the password hash.
|
||||
const sendHashedPasswordB64 = Utils.fromBufferToB64(
|
||||
sendHashedPasswordArray,
|
||||
) as SendHashedPasswordB64;
|
||||
|
||||
return sendHashedPasswordB64;
|
||||
}
|
||||
|
||||
private async getSendAccessTokenFromStorage(
|
||||
sendId: string,
|
||||
): Promise<SendAccessToken | undefined> {
|
||||
if (this.sendAccessTokenDictGlobalState != null) {
|
||||
const sendAccessTokenDict = await firstValueFrom(this.sendAccessTokenDictGlobalState.state$);
|
||||
return sendAccessTokenDict?.[sendId];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async setSendAccessTokenInStorage(
|
||||
sendId: string,
|
||||
sendAccessToken: SendAccessToken,
|
||||
): Promise<void> {
|
||||
if (this.sendAccessTokenDictGlobalState != null) {
|
||||
await this.sendAccessTokenDictGlobalState.update(
|
||||
(sendAccessTokenDict) => {
|
||||
sendAccessTokenDict ??= {}; // Initialize if undefined
|
||||
|
||||
sendAccessTokenDict[sendId] = sendAccessToken;
|
||||
return sendAccessTokenDict;
|
||||
},
|
||||
{
|
||||
// only update if the value is different (to avoid unnecessary writes)
|
||||
shouldUpdate: (prevDict) => {
|
||||
const prevSendAccessToken = prevDict?.[sendId];
|
||||
return (
|
||||
prevSendAccessToken?.token !== sendAccessToken.token ||
|
||||
prevSendAccessToken?.expiresAt !== sendAccessToken.expiresAt
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearSendAccessTokenFromStorage(sendId: string): Promise<void> {
|
||||
if (this.sendAccessTokenDictGlobalState != null) {
|
||||
await this.sendAccessTokenDictGlobalState.update(
|
||||
(sendAccessTokenDict) => {
|
||||
if (!sendAccessTokenDict) {
|
||||
// If the dict is empty or undefined, there's nothing to clear
|
||||
return sendAccessTokenDict;
|
||||
}
|
||||
|
||||
if (sendAccessTokenDict[sendId] == null) {
|
||||
// If the specific sendId does not exist, nothing to clear
|
||||
return sendAccessTokenDict;
|
||||
}
|
||||
|
||||
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [sendId]: _, ...rest } = sendAccessTokenDict;
|
||||
|
||||
return rest;
|
||||
},
|
||||
{
|
||||
// only update if the value is defined (to avoid unnecessary writes)
|
||||
shouldUpdate: (prevDict) => prevDict?.[sendId] != null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an error from the SDK send access token request process.
|
||||
* @param e The error to normalize.
|
||||
* @returns A normalized GetSendAccessTokenError.
|
||||
*/
|
||||
private normalizeSendAccessTokenError(e: unknown): GetSendAccessTokenError {
|
||||
if (this.isSendAccessTokenError(e)) {
|
||||
if (e.kind === "unexpected") {
|
||||
return { kind: "unexpected_server", error: e.data };
|
||||
}
|
||||
return { kind: "expected_server", error: e.data };
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return { kind: "unknown", error: e.message };
|
||||
}
|
||||
|
||||
try {
|
||||
return { kind: "unknown", error: JSON.stringify(e) };
|
||||
} catch {
|
||||
return { kind: "unknown", error: "error cannot be stringified" };
|
||||
}
|
||||
}
|
||||
|
||||
private isSendAccessTokenError(e: unknown): e is SendAccessTokenError {
|
||||
return (
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
"kind" in e &&
|
||||
(e.kind === "expected" || e.kind === "unexpected")
|
||||
);
|
||||
}
|
||||
|
||||
private validateSendId(sendId: string): void {
|
||||
if (sendId == null || sendId.trim() === "") {
|
||||
throw new Error("sendId must be provided.");
|
||||
}
|
||||
}
|
||||
|
||||
private validateCredentialsRequest(
|
||||
sendId: string,
|
||||
sendAccessCredentials: SendAccessDomainCredentials,
|
||||
): void {
|
||||
this.validateSendId(sendId);
|
||||
if (sendAccessCredentials == null) {
|
||||
throw new Error("sendAccessCredentials must be provided.");
|
||||
}
|
||||
|
||||
if (sendAccessCredentials.kind === "password" && !sendAccessCredentials.passwordHashB64) {
|
||||
throw new Error("passwordHashB64 must be provided for password credentials.");
|
||||
}
|
||||
|
||||
if (sendAccessCredentials.kind === "email" && !sendAccessCredentials.email) {
|
||||
throw new Error("email must be provided for email credentials.");
|
||||
}
|
||||
|
||||
if (
|
||||
sendAccessCredentials.kind === "email_otp" &&
|
||||
(!sendAccessCredentials.email || !sendAccessCredentials.otp)
|
||||
) {
|
||||
throw new Error("email and otp must be provided for email_otp credentials.");
|
||||
}
|
||||
}
|
||||
|
||||
private convertDomainCredentialsToSdkCredentials(
|
||||
sendAccessCredentials: SendAccessDomainCredentials,
|
||||
): SendAccessCredentials {
|
||||
switch (sendAccessCredentials.kind) {
|
||||
case "password":
|
||||
return {
|
||||
passwordHashB64: sendAccessCredentials.passwordHashB64,
|
||||
};
|
||||
case "email":
|
||||
return {
|
||||
email: sendAccessCredentials.email,
|
||||
};
|
||||
case "email_otp":
|
||||
return {
|
||||
email: sendAccessCredentials.email,
|
||||
otp: sendAccessCredentials.otp,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/common/src/auth/send-access/services/index.ts
Normal file
1
libs/common/src/auth/send-access/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./default-send-token.service";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { KeyDefinition, SEND_ACCESS_DISK } from "@bitwarden/state";
|
||||
|
||||
import { SendAccessToken } from "../models/send-access-token";
|
||||
|
||||
export const SEND_ACCESS_TOKEN_DICT = KeyDefinition.record<SendAccessToken, string>(
|
||||
SEND_ACCESS_DISK,
|
||||
"accessTokenDict",
|
||||
{
|
||||
deserializer: (sendAccessTokenJson: Jsonify<SendAccessToken>) => {
|
||||
return SendAccessToken.fromJson(sendAccessTokenJson);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UnexpectedIdentityError, SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
/**
|
||||
* Represents the possible errors that can occur when retrieving a SendAccessToken.
|
||||
* Note: for expected_server errors, see invalid-request-errors.type.ts and
|
||||
* invalid-grant-errors.type.ts for type guards that identify specific
|
||||
* SendAccessTokenApiErrorResponse errors
|
||||
*/
|
||||
export type GetSendAccessTokenError =
|
||||
| { kind: "unexpected_server"; error: UnexpectedIdentityError }
|
||||
| { kind: "expected_server"; error: SendAccessTokenApiErrorResponse }
|
||||
| { kind: "unknown"; error: string };
|
||||
7
libs/common/src/auth/send-access/types/index.ts
Normal file
7
libs/common/src/auth/send-access/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./try-get-send-access-token-error.type";
|
||||
export * from "./send-otp.type";
|
||||
export * from "./send-hashed-password-b64.type";
|
||||
export * from "./send-access-domain-credentials.type";
|
||||
export * from "./invalid-request-errors.type";
|
||||
export * from "./invalid-grant-errors.type";
|
||||
export * from "./get-send-access-token-error.type";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
export type InvalidGrant = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_grant" }>;
|
||||
|
||||
export function isInvalidGrant(e: SendAccessTokenApiErrorResponse): e is InvalidGrant {
|
||||
return e.error === "invalid_grant";
|
||||
}
|
||||
|
||||
export type BareInvalidGrant = Extract<
|
||||
SendAccessTokenApiErrorResponse,
|
||||
{ error: "invalid_grant" }
|
||||
> & { send_access_error_type?: undefined };
|
||||
|
||||
export function isBareInvalidGrant(e: SendAccessTokenApiErrorResponse): e is BareInvalidGrant {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === undefined;
|
||||
}
|
||||
|
||||
export type SendIdInvalid = InvalidGrant & {
|
||||
send_access_error_type: "send_id_invalid";
|
||||
};
|
||||
export function sendIdInvalid(e: SendAccessTokenApiErrorResponse): e is SendIdInvalid {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "send_id_invalid";
|
||||
}
|
||||
|
||||
export type PasswordHashB64Invalid = InvalidGrant & {
|
||||
send_access_error_type: "password_hash_b64_invalid";
|
||||
};
|
||||
export function passwordHashB64Invalid(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is PasswordHashB64Invalid {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
|
||||
}
|
||||
|
||||
export type EmailInvalid = InvalidGrant & {
|
||||
send_access_error_type: "email_invalid";
|
||||
};
|
||||
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
|
||||
}
|
||||
|
||||
export type OtpInvalid = InvalidGrant & {
|
||||
send_access_error_type: "otp_invalid";
|
||||
};
|
||||
export function otpInvalid(e: SendAccessTokenApiErrorResponse): e is OtpInvalid {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "otp_invalid";
|
||||
}
|
||||
|
||||
export type OtpGenerationFailed = InvalidGrant & {
|
||||
send_access_error_type: "otp_generation_failed";
|
||||
};
|
||||
export function otpGenerationFailed(e: SendAccessTokenApiErrorResponse): e is OtpGenerationFailed {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "otp_generation_failed";
|
||||
}
|
||||
|
||||
export type UnknownInvalidGrant = InvalidGrant & {
|
||||
send_access_error_type: "unknown";
|
||||
};
|
||||
export function isUnknownInvalidGrant(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is UnknownInvalidGrant {
|
||||
return e.error === "invalid_grant" && e.send_access_error_type === "unknown";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
export type InvalidRequest = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_request" }>;
|
||||
|
||||
export function isInvalidRequest(e: SendAccessTokenApiErrorResponse): e is InvalidRequest {
|
||||
return e.error === "invalid_request";
|
||||
}
|
||||
|
||||
export type BareInvalidRequest = Extract<
|
||||
SendAccessTokenApiErrorResponse,
|
||||
{ error: "invalid_request" }
|
||||
> & { send_access_error_type?: undefined };
|
||||
|
||||
export function isBareInvalidRequest(e: SendAccessTokenApiErrorResponse): e is BareInvalidRequest {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === undefined;
|
||||
}
|
||||
|
||||
export type SendIdRequired = InvalidRequest & {
|
||||
send_access_error_type: "send_id_required";
|
||||
};
|
||||
|
||||
export function sendIdRequired(e: SendAccessTokenApiErrorResponse): e is SendIdRequired {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "send_id_required";
|
||||
}
|
||||
|
||||
export type PasswordHashB64Required = InvalidRequest & {
|
||||
send_access_error_type: "password_hash_b64_required";
|
||||
};
|
||||
|
||||
export function passwordHashB64Required(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is PasswordHashB64Required {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "password_hash_b64_required";
|
||||
}
|
||||
|
||||
export type EmailRequired = InvalidRequest & { send_access_error_type: "email_required" };
|
||||
|
||||
export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailRequired {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
|
||||
}
|
||||
|
||||
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
|
||||
send_access_error_type: "email_and_otp_required_otp_sent";
|
||||
};
|
||||
|
||||
export function emailAndOtpRequiredEmailSent(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is EmailAndOtpRequiredEmailSent {
|
||||
return (
|
||||
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
|
||||
);
|
||||
}
|
||||
|
||||
export type UnknownInvalidRequest = InvalidRequest & {
|
||||
send_access_error_type: "unknown";
|
||||
};
|
||||
|
||||
export function isUnknownInvalidRequest(
|
||||
e: SendAccessTokenApiErrorResponse,
|
||||
): e is UnknownInvalidRequest {
|
||||
return e.error === "invalid_request" && e.send_access_error_type === "unknown";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SendHashedPasswordB64 } from "./send-hashed-password-b64.type";
|
||||
import { SendOtp } from "./send-otp.type";
|
||||
|
||||
/**
|
||||
* The domain facing send access credentials
|
||||
* Will be internally mapped to the SDK types
|
||||
*/
|
||||
export type SendAccessDomainCredentials =
|
||||
| { kind: "password"; passwordHashB64: SendHashedPasswordB64 }
|
||||
| { kind: "email"; email: string }
|
||||
| { kind: "email_otp"; email: string; otp: SendOtp };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;
|
||||
3
libs/common/src/auth/send-access/types/send-otp.type.ts
Normal file
3
libs/common/src/auth/send-access/types/send-otp.type.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
export type SendOtp = Opaque<string, "SendOtp">;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { GetSendAccessTokenError } from "./get-send-access-token-error.type";
|
||||
|
||||
/**
|
||||
* Represents the possible errors that can occur when trying to retrieve a SendAccessToken by
|
||||
* just a sendId. Extends {@link GetSendAccessTokenError}.
|
||||
*/
|
||||
export type TryGetSendAccessTokenError = { kind: "expired" } | GetSendAccessTokenError;
|
||||
@@ -1,24 +1,26 @@
|
||||
<aside
|
||||
class="tw-mb-4 tw-box-border tw-rounded-lg tw-bg-background tw-ps-3 tw-pe-3 tw-py-2 tw-leading-5 tw-text-main"
|
||||
[ngClass]="calloutClass()"
|
||||
class="tw-mb-4 tw-box-border tw-border tw-border-solid tw-rounded-lg tw-bg-background tw-ps-4 tw-pe-4 tw-py-3 tw-leading-5 tw-flex tw-gap-2"
|
||||
[ngClass]="[calloutClass()]"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
>
|
||||
@if (titleComputed(); as title) {
|
||||
<header
|
||||
id="{{ titleId }}"
|
||||
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-start"
|
||||
>
|
||||
@if (iconComputed(); as icon) {
|
||||
<i
|
||||
class="bwi !tw-text-main tw-relative tw-top-[3px]"
|
||||
[ngClass]="[icon]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
}
|
||||
{{ title }}
|
||||
</header>
|
||||
@let title = titleComputed();
|
||||
@let icon = iconComputed();
|
||||
|
||||
@if (icon) {
|
||||
<i
|
||||
class="bwi tw-relative"
|
||||
[ngClass]="[icon, title ? 'tw-top-[3px] tw-self-start' : 'tw-top-[1px]']"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
}
|
||||
<div class="tw-ps-6" bitTypography="body2">
|
||||
<ng-content></ng-content>
|
||||
<div class="tw-flex tw-flex-col tw-gap-0.5">
|
||||
@if (title) {
|
||||
<header id="{{ titleId }}" class="tw-text-base tw-font-semibold">
|
||||
{{ title }}
|
||||
</header>
|
||||
}
|
||||
<div bitTypography="body2">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -56,5 +56,12 @@ describe("Callout", () => {
|
||||
expect(component.titleComputed()).toBe("Error");
|
||||
expect(component.iconComputed()).toBe("bwi-error");
|
||||
});
|
||||
|
||||
it("default", () => {
|
||||
fixture.componentRef.setInput("type", "default");
|
||||
fixture.detectChanges();
|
||||
expect(component.titleComputed()).toBeUndefined();
|
||||
expect(component.iconComputed()).toBe("bwi-star");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
export type CalloutTypes = "success" | "info" | "warning" | "danger";
|
||||
export type CalloutTypes = "success" | "info" | "warning" | "danger" | "default";
|
||||
|
||||
const defaultIcon: Record<CalloutTypes, string> = {
|
||||
success: "bwi-check-circle",
|
||||
info: "bwi-info-circle",
|
||||
warning: "bwi-exclamation-triangle",
|
||||
danger: "bwi-error",
|
||||
default: "bwi-star",
|
||||
};
|
||||
|
||||
const defaultI18n: Partial<Record<CalloutTypes, string>> = {
|
||||
@@ -55,13 +56,15 @@ export class CalloutComponent {
|
||||
protected readonly calloutClass = computed(() => {
|
||||
switch (this.type()) {
|
||||
case "danger":
|
||||
return "tw-bg-danger-100";
|
||||
return "tw-bg-danger-100 tw-border-danger-700 tw-text-danger-700";
|
||||
case "info":
|
||||
return "tw-bg-info-100";
|
||||
return "tw-bg-info-100 tw-bg-info-100 tw-border-info-700 tw-text-info-700";
|
||||
case "success":
|
||||
return "tw-bg-success-100";
|
||||
return "tw-bg-success-100 tw-bg-success-100 tw-border-success-700 tw-text-success-700";
|
||||
case "warning":
|
||||
return "tw-bg-warning-100";
|
||||
return "tw-bg-warning-100 tw-bg-warning-100 tw-border-warning-700 tw-text-warning-700";
|
||||
case "default":
|
||||
return "tw-bg-background-alt tw-border-secondary-700 tw-text-secondary-700";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ automatically be checked.
|
||||
|
||||
<Canvas of={stories.Info} />
|
||||
|
||||
### Default
|
||||
|
||||
Use for similar cases as the info callout but when content does not need to be as prominent.
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
|
||||
### Warning
|
||||
|
||||
Use a warning callout if the user is about to perform an action that may have unintended or
|
||||
@@ -67,4 +73,8 @@ Use the `role=”alert”` only if the callout is appearing on a page after the
|
||||
the content is static, do not use the alert role. This will cause a screen reader to announce the
|
||||
callout content on page load.
|
||||
|
||||
Ensure the title's color contrast remains WCAG compliant with the callout's background.
|
||||
Ensure color contrast remains WCAG compliant with the callout's background. This is especially
|
||||
important when adding `bit-link` or `bit-button` to the content area since the callout background is
|
||||
colored. Currently only the `info` and `default` callouts are WCAG compliant for the `primary`
|
||||
styling of these elements. The `secondary` `bit-link` styling may be used with the remaining
|
||||
variants.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LinkModule, IconModule } from "@bitwarden/components";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
@@ -12,6 +13,7 @@ export default {
|
||||
component: CalloutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [LinkModule, IconModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -69,6 +71,14 @@ export const Danger: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
...Info,
|
||||
args: {
|
||||
...Info.args,
|
||||
type: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomIcon: Story = {
|
||||
...Info,
|
||||
args: {
|
||||
@@ -80,6 +90,35 @@ export const CustomIcon: Story = {
|
||||
export const NoTitle: Story = {
|
||||
...Info,
|
||||
args: {
|
||||
icon: "bwi-star",
|
||||
icon: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTitleWithIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>The content of the callout</bit-callout>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
type: "default",
|
||||
icon: "bwi-globe",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTextButton: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>
|
||||
<p class="tw-mb-2">The content of the callout</p>
|
||||
<a bitLink> Visit the help center<i aria-hidden="true" class="bwi bwi-fw bwi-sm bwi-angle-right"></i> </a>
|
||||
</bit-callout>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
type: "default",
|
||||
icon: "",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -130,7 +130,11 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
.concat(sizes[this.size()])
|
||||
.concat(
|
||||
this.showDisabledStyles() || this.disabled()
|
||||
? ["aria-disabled:tw-opacity-60", "aria-disabled:hover:!tw-bg-transparent"]
|
||||
? [
|
||||
"aria-disabled:tw-opacity-60",
|
||||
"aria-disabled:hover:!tw-bg-transparent",
|
||||
"tw-cursor-default",
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
loading: "Loading",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
1
libs/components/src/tooltip/index.ts
Normal file
1
libs/components/src/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./tooltip.directive";
|
||||
61
libs/components/src/tooltip/tooltip-positions.ts
Normal file
61
libs/components/src/tooltip/tooltip-positions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConnectedPosition } from "@angular/cdk/overlay";
|
||||
|
||||
const ORIGIN_OFFSET_PX = 10;
|
||||
|
||||
export type TooltipPositionIdentifier =
|
||||
| "right-center"
|
||||
| "left-center"
|
||||
| "below-center"
|
||||
| "above-center";
|
||||
|
||||
export interface TooltipPosition extends ConnectedPosition {
|
||||
id: TooltipPositionIdentifier;
|
||||
}
|
||||
|
||||
export const tooltipPositions: TooltipPosition[] = [
|
||||
/**
|
||||
* The order of these positions matters. The Tooltip component will use
|
||||
* the first position that fits within the viewport.
|
||||
*/
|
||||
|
||||
// Tooltip opens to right of trigger
|
||||
{
|
||||
id: "right-center",
|
||||
offsetX: ORIGIN_OFFSET_PX,
|
||||
originX: "end",
|
||||
originY: "center",
|
||||
overlayX: "start",
|
||||
overlayY: "center",
|
||||
panelClass: ["bit-tooltip-right-center"],
|
||||
},
|
||||
// ... to left of trigger
|
||||
{
|
||||
id: "left-center",
|
||||
offsetX: -ORIGIN_OFFSET_PX,
|
||||
originX: "start",
|
||||
originY: "center",
|
||||
overlayX: "end",
|
||||
overlayY: "center",
|
||||
panelClass: ["bit-tooltip-left-center"],
|
||||
},
|
||||
// ... below trigger
|
||||
{
|
||||
id: "below-center",
|
||||
offsetY: ORIGIN_OFFSET_PX,
|
||||
originX: "center",
|
||||
originY: "bottom",
|
||||
overlayX: "center",
|
||||
overlayY: "top",
|
||||
panelClass: ["bit-tooltip-below-center"],
|
||||
},
|
||||
// ... above trigger
|
||||
{
|
||||
id: "above-center",
|
||||
offsetY: -ORIGIN_OFFSET_PX,
|
||||
originX: "center",
|
||||
originY: "top",
|
||||
overlayX: "center",
|
||||
overlayY: "bottom",
|
||||
panelClass: ["bit-tooltip-above-center"],
|
||||
},
|
||||
];
|
||||
132
libs/components/src/tooltip/tooltip.component.css
Normal file
132
libs/components/src/tooltip/tooltip.component.css
Normal file
@@ -0,0 +1,132 @@
|
||||
:root {
|
||||
--tooltip-shadow: rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.cdk-overlay-pane:has(.bit-tooltip[data-visible="false"]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bit-tooltip-container {
|
||||
position: relative;
|
||||
max-width: 12rem;
|
||||
opacity: 0;
|
||||
width: max-content;
|
||||
box-shadow:
|
||||
0 4px 6px -1px var(--tooltip-shadow),
|
||||
0 2px 4px -2px var(--tooltip-shadow);
|
||||
border-radius: 0.75rem;
|
||||
transition:
|
||||
transform 100ms ease-in-out,
|
||||
opacity 100ms ease-in-out;
|
||||
transform: scale(0.95);
|
||||
z-index: 1;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
z-index: 1;
|
||||
rotate: 45deg;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(135deg, transparent 50%, rgb(var(--color-text-main)) 50%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: rgb(var(--color-text-main));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bit-tooltip-above-center &,
|
||||
.bit-tooltip-below-center & {
|
||||
&::before,
|
||||
&::after {
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform-origin: left;
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip-above-center & {
|
||||
&::after {
|
||||
filter: drop-shadow(0 3px 5px var(--tooltip-shadow))
|
||||
drop-shadow(0 1px 3px var(--tooltip-shadow));
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
inset-block-end: -0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip-below-center & {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
inset-block-start: -0.25rem;
|
||||
rotate: -135deg;
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip-left-center &,
|
||||
.bit-tooltip-right-center & {
|
||||
&::after,
|
||||
&::before {
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform-origin: top;
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip-left-center & {
|
||||
&::after {
|
||||
filter: drop-shadow(-3px 1px 3px var(--tooltip-shadow))
|
||||
drop-shadow(-1px 2px 3px var(--tooltip-shadow));
|
||||
}
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
inset-inline-end: -0.25rem;
|
||||
rotate: -45deg;
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip-right-center & {
|
||||
&::after {
|
||||
filter: drop-shadow(2px -4px 2px var(--tooltip-shadow))
|
||||
drop-shadow(0 -1px 3px var(--tooltip-shadow));
|
||||
}
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
inset-inline-start: -0.25rem;
|
||||
rotate: 135deg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bit-tooltip {
|
||||
width: max-content;
|
||||
max-width: 12rem;
|
||||
background-color: rgb(var(--color-text-main));
|
||||
color: rgb(var(--color-text-contrast));
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
z-index: 2;
|
||||
}
|
||||
10
libs/components/src/tooltip/tooltip.component.html
Normal file
10
libs/components/src/tooltip/tooltip.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
|
||||
<div
|
||||
class="bit-tooltip-container"
|
||||
[attr.data-position]="tooltipData.tooltipPosition()"
|
||||
[attr.data-visible]="tooltipData.isVisible()"
|
||||
>
|
||||
<div role="tooltip" class="bit-tooltip">
|
||||
<ng-content>{{ tooltipData.content() }}</ng-content>
|
||||
</div>
|
||||
</div>
|
||||
36
libs/components/src/tooltip/tooltip.component.ts
Normal file
36
libs/components/src/tooltip/tooltip.component.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
InjectionToken,
|
||||
Signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TooltipPosition } from "./tooltip-positions";
|
||||
|
||||
type TooltipData = {
|
||||
content: Signal<string>;
|
||||
isVisible: Signal<boolean>;
|
||||
tooltipPosition: Signal<TooltipPosition>;
|
||||
};
|
||||
|
||||
export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA");
|
||||
|
||||
/**
|
||||
* tooltip component used internally by the tooltip.directive. Not meant to be used explicitly
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-tooltip",
|
||||
templateUrl: "./tooltip.component.html",
|
||||
imports: [CommonModule],
|
||||
})
|
||||
export class TooltipComponent {
|
||||
readonly templateRef = viewChild.required(TemplateRef);
|
||||
|
||||
private elementRef = inject(ElementRef<HTMLDivElement>);
|
||||
|
||||
readonly tooltipData = inject<TooltipData>(TOOLTIP_DATA);
|
||||
}
|
||||
110
libs/components/src/tooltip/tooltip.directive.ts
Normal file
110
libs/components/src/tooltip/tooltip.directive.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { ComponentPortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
Directive,
|
||||
ViewContainerRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ElementRef,
|
||||
Injector,
|
||||
input,
|
||||
effect,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
|
||||
import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component";
|
||||
|
||||
/**
|
||||
* Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input.
|
||||
* The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center".
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitTooltip]",
|
||||
host: {
|
||||
"(mouseenter)": "showTooltip()",
|
||||
"(mouseleave)": "hideTooltip()",
|
||||
"(focus)": "showTooltip()",
|
||||
"(blur)": "hideTooltip()",
|
||||
},
|
||||
})
|
||||
export class TooltipDirective implements OnInit {
|
||||
/**
|
||||
* The value of this input is forwarded to the tooltip.component to render
|
||||
*/
|
||||
readonly bitTooltip = input.required<string>();
|
||||
/**
|
||||
* The value of this input is forwarded to the tooltip.component to set its position explicitly.
|
||||
* @default "above-center"
|
||||
*/
|
||||
readonly tooltipPosition = input<TooltipPositionIdentifier>("above-center");
|
||||
|
||||
private isVisible = signal(false);
|
||||
private overlayRef: OverlayRef | undefined;
|
||||
private elementRef = inject(ElementRef);
|
||||
private overlay = inject(Overlay);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
private injector = inject(Injector);
|
||||
private positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(this.elementRef)
|
||||
.withFlexibleDimensions(false)
|
||||
.withPush(true);
|
||||
|
||||
private tooltipPortal = new ComponentPortal(
|
||||
TooltipComponent,
|
||||
this.viewContainerRef,
|
||||
Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: TOOLTIP_DATA,
|
||||
useValue: {
|
||||
content: this.bitTooltip,
|
||||
isVisible: this.isVisible,
|
||||
tooltipPosition: this.tooltipPosition,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
private showTooltip = () => {
|
||||
this.isVisible.set(true);
|
||||
};
|
||||
|
||||
private hideTooltip = () => {
|
||||
this.isVisible.set(false);
|
||||
};
|
||||
|
||||
private computePositions(tooltipPosition: TooltipPositionIdentifier) {
|
||||
const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition);
|
||||
|
||||
return chosenPosition ? [chosenPosition, ...tooltipPositions] : tooltipPositions;
|
||||
}
|
||||
|
||||
get defaultPopoverConfig(): OverlayConfig {
|
||||
return {
|
||||
hasBackdrop: false,
|
||||
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
||||
};
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
...this.defaultPopoverConfig,
|
||||
positionStrategy: this.positionStrategy,
|
||||
});
|
||||
|
||||
this.overlayRef.attach(this.tooltipPortal);
|
||||
|
||||
effect(
|
||||
() => {
|
||||
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
|
||||
this.overlayRef?.updatePosition();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
}
|
||||
31
libs/components/src/tooltip/tooltip.mdx
Normal file
31
libs/components/src/tooltip/tooltip.mdx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./tooltip.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { TooltipDirective } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### All available positions
|
||||
|
||||
<Canvas of={stories.AllPositions} />
|
||||
|
||||
### Used with a long content
|
||||
|
||||
<Canvas of={stories.LongContent} />
|
||||
|
||||
### On disabled element
|
||||
|
||||
<Canvas of={stories.OnDisabledButton} />
|
||||
103
libs/components/src/tooltip/tooltip.spec.ts
Normal file
103
libs/components/src/tooltip/tooltip.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ConnectedOverlayPositionChange,
|
||||
ConnectionPositionPair,
|
||||
OverlayConfig,
|
||||
Overlay,
|
||||
} from "@angular/cdk/overlay";
|
||||
import { ComponentPortal } from "@angular/cdk/portal";
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { TooltipDirective } from "./tooltip.directive";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TooltipDirective],
|
||||
template: ` <button [bitTooltip]="tooltipText" type="button">Hover or focus me</button> `,
|
||||
})
|
||||
class TooltipHostComponent {
|
||||
tooltipText = "Hello Tooltip";
|
||||
}
|
||||
|
||||
/** Minimal strategy shape the directive expects */
|
||||
interface StrategyLike {
|
||||
withFlexibleDimensions: (flex: boolean) => StrategyLike;
|
||||
withPush: (push: boolean) => StrategyLike;
|
||||
withPositions: (positions: ReadonlyArray<ConnectionPositionPair>) => StrategyLike;
|
||||
readonly positionChanges: Observable<ConnectedOverlayPositionChange>;
|
||||
}
|
||||
|
||||
/** Minimal Overlay service shape */
|
||||
interface OverlayLike {
|
||||
position: () => { flexibleConnectedTo: (_: unknown) => StrategyLike };
|
||||
create: (_: OverlayConfig) => OverlayRefStub;
|
||||
scrollStrategies: { reposition: () => unknown };
|
||||
}
|
||||
|
||||
interface OverlayRefStub {
|
||||
attach: (portal: ComponentPortal<unknown>) => unknown;
|
||||
updatePosition: () => void;
|
||||
}
|
||||
|
||||
describe("TooltipDirective (visibility only)", () => {
|
||||
let fixture: ComponentFixture<TooltipHostComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
const positionChanges$ = new Subject<ConnectedOverlayPositionChange>();
|
||||
|
||||
const strategy: StrategyLike = {
|
||||
withFlexibleDimensions: jest.fn(() => strategy),
|
||||
withPush: jest.fn(() => strategy),
|
||||
withPositions: jest.fn(() => strategy),
|
||||
get positionChanges() {
|
||||
return positionChanges$.asObservable();
|
||||
},
|
||||
};
|
||||
|
||||
const overlayRefStub: OverlayRefStub = {
|
||||
attach: jest.fn(() => ({})),
|
||||
updatePosition: jest.fn(),
|
||||
};
|
||||
|
||||
const overlayMock: OverlayLike = {
|
||||
position: () => ({ flexibleConnectedTo: () => strategy }),
|
||||
create: (_: OverlayConfig) => overlayRefStub,
|
||||
scrollStrategies: { reposition: () => ({}) },
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TooltipHostComponent],
|
||||
providers: [{ provide: Overlay, useValue: overlayMock as unknown as Overlay }],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(TooltipHostComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
function getDirective(): TooltipDirective {
|
||||
const hostDE = fixture.debugElement.query(By.directive(TooltipDirective));
|
||||
return hostDE.injector.get(TooltipDirective);
|
||||
}
|
||||
|
||||
it("sets isVisible to true on mouseenter", () => {
|
||||
const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
|
||||
const directive = getDirective();
|
||||
|
||||
const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
|
||||
|
||||
button.dispatchEvent(new Event("mouseenter"));
|
||||
expect(isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isVisible to true on focus", () => {
|
||||
const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
|
||||
const directive = getDirective();
|
||||
|
||||
const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
|
||||
|
||||
button.dispatchEvent(new Event("focus"));
|
||||
expect(isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
153
libs/components/src/tooltip/tooltip.stories.ts
Normal file
153
libs/components/src/tooltip/tooltip.stories.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { signal } from "@angular/core";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { getByRole, userEvent } from "@storybook/test";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { ButtonComponent } from "../button";
|
||||
import { BitIconButtonComponent } from "../icon-button";
|
||||
import { I18nMockService } from "../utils";
|
||||
|
||||
import { TooltipPosition, TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
|
||||
import { TOOLTIP_DATA, TooltipComponent } from "./tooltip.component";
|
||||
import { TooltipDirective } from "./tooltip.directive";
|
||||
|
||||
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Tooltip",
|
||||
component: TooltipDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [TooltipDirective, TooltipComponent, BitIconButtonComponent, ButtonComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
loading: "Loading",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TOOLTIP_DATA,
|
||||
useFactory: () => {
|
||||
// simple fixed demo values for the Default story
|
||||
return {
|
||||
content: signal("This is a tooltip"),
|
||||
isVisible: signal(true),
|
||||
tooltipPosition: signal<TooltipPositionIdentifier>("above-center"),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?m=auto&node-id=30558-13730&t=4k23PtzCwqDekAZW-1",
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
bitTooltip: {
|
||||
control: "text",
|
||||
description: "Text content of the tooltip",
|
||||
},
|
||||
tooltipPosition: {
|
||||
control: "select",
|
||||
options: tooltipPositions.map((position: TooltipPosition) => position.id),
|
||||
description: "Position of the tooltip relative to the element",
|
||||
table: {
|
||||
type: {
|
||||
summary: tooltipPositions.map((position: TooltipPosition) => position.id).join(" | "),
|
||||
},
|
||||
defaultValue: { summary: "above-center" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta<TooltipDirective>;
|
||||
|
||||
type Story = StoryObj<TooltipDirective>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
bitTooltip: "This is a tooltip",
|
||||
tooltipPosition: "above-center",
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-p-4">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
${formatArgsForCodeSnippet<TooltipDirective>(args)}
|
||||
>
|
||||
Button label here
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
play: async (context) => {
|
||||
const canvasEl = context.canvasElement;
|
||||
const button = getByRole(canvasEl, "button");
|
||||
|
||||
await userEvent.hover(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllPositions: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center">
|
||||
<button
|
||||
bitIconButton="bwi-angle-up"
|
||||
bitTooltip="Top tooltip"
|
||||
tooltipPosition="above-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-right"
|
||||
bitTooltip="Right tooltip"
|
||||
tooltipPosition="right-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-left"
|
||||
bitTooltip="Left tooltip"
|
||||
tooltipPosition="left-center"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-angle-down"
|
||||
bitTooltip="Bottom tooltip"
|
||||
tooltipPosition="below-center"
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
bitTooltip="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability."
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const OnDisabledButton: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
|
||||
<button
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
bitTooltip="Tooltip on disabled button"
|
||||
[disabled]="true"
|
||||
></button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
@import "./popover/popover.component.css";
|
||||
@import "./toast/toast.tokens.css";
|
||||
@import "./toast/toastr.css";
|
||||
@import "./tooltip/tooltip.component.css";
|
||||
@import "./search/search.component.css";
|
||||
|
||||
@tailwind base;
|
||||
|
||||
@@ -64,12 +64,13 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
|
||||
private async parseEncrypted(
|
||||
results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport,
|
||||
) {
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
if (results.encKeyValidation_DO_NOT_EDIT != null) {
|
||||
let keyForDecryption: SymmetricCryptoKey = await this.keyService.getOrgKey(
|
||||
this.organizationId,
|
||||
);
|
||||
const orgKeys = await firstValueFrom(this.keyService.orgKeys$(account.id));
|
||||
let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId];
|
||||
if (keyForDecryption == null) {
|
||||
keyForDecryption = await this.keyService.getUserKey();
|
||||
keyForDecryption = await firstValueFrom(this.keyService.userKey$(account.id));
|
||||
}
|
||||
const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT);
|
||||
try {
|
||||
@@ -113,10 +114,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
|
||||
});
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const view = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
const view = await this.cipherService.decrypt(cipher, account.id);
|
||||
this.cleanupCipher(view);
|
||||
this.result.ciphers.push(view);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { emptyAccountEncrypted } from "../spec-data/bitwarden-json/account-encrypted.json";
|
||||
import { emptyUnencryptedExport } from "../spec-data/bitwarden-json/unencrypted.json";
|
||||
@@ -35,6 +40,36 @@ describe("BitwardenPasswordProtectedImporter", () => {
|
||||
pinService = mock<PinServiceAbstraction>();
|
||||
accountService = mock<AccountService>();
|
||||
|
||||
accountService.activeAccount$ = of({
|
||||
id: newGuid() as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
const mockOrgId = emptyGuid as OrganizationId;
|
||||
/*
|
||||
The key values below are never read, empty objects are cast as types for compilation type checking only.
|
||||
Tests specific to key contents are in key-service.spec.ts
|
||||
*/
|
||||
const mockOrgKey = {} as unknown as OrgKey;
|
||||
const mockUserKey = {} as unknown as UserKey;
|
||||
|
||||
keyService.orgKeys$.mockImplementation(() =>
|
||||
of({ [mockOrgId]: mockOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
);
|
||||
keyService.userKey$.mockImplementation(() => of(mockUserKey));
|
||||
(keyService as any).activeUserOrgKeys$ = of({
|
||||
[mockOrgId]: mockOrgKey,
|
||||
} as Record<OrganizationId, OrgKey>);
|
||||
|
||||
/*
|
||||
Crypto isn’t under test here; keys are just placeholders.
|
||||
Decryption methods are stubbed to always return empty CipherView or string allowing OK import flow.
|
||||
*/
|
||||
cipherService.decrypt.mockResolvedValue({} as any);
|
||||
encryptService.decryptString.mockResolvedValue("ok");
|
||||
|
||||
importer = new BitwardenPasswordProtectedImporter(
|
||||
keyService,
|
||||
encryptService,
|
||||
@@ -62,6 +97,24 @@ describe("BitwardenPasswordProtectedImporter", () => {
|
||||
jest.spyOn(BitwardenJsonImporter.prototype, "parse");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
accountService.activeAccount$ = of({
|
||||
id: newGuid() as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
});
|
||||
importer = new BitwardenPasswordProtectedImporter(
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
cipherService,
|
||||
pinService,
|
||||
accountService,
|
||||
promptForPassword_callback,
|
||||
);
|
||||
});
|
||||
|
||||
it("Should call BitwardenJsonImporter", async () => {
|
||||
expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true);
|
||||
expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted);
|
||||
|
||||
@@ -72,6 +72,7 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
|
||||
export const SEND_ACCESS_DISK = new StateDefinition("sendAccess", "disk");
|
||||
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
||||
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
|
||||
export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk");
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -22,6 +23,7 @@ import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
@@ -112,7 +114,7 @@ export class OrganizationVaultExportService
|
||||
type: "text/plain",
|
||||
data: onlyManagedCollections
|
||||
? await this.getEncryptedManagedExport(userId, organizationId)
|
||||
: await this.getOrganizationEncryptedExport(organizationId),
|
||||
: await this.getOrganizationEncryptedExport(userId, organizationId),
|
||||
fileName: ExportHelper.getFileName("org", "encrypted_json"),
|
||||
} as ExportedVaultAsString;
|
||||
}
|
||||
@@ -184,7 +186,10 @@ export class OrganizationVaultExportService
|
||||
return this.buildJsonExport(decCollections, decCiphers);
|
||||
}
|
||||
|
||||
private async getOrganizationEncryptedExport(organizationId: OrganizationId): Promise<string> {
|
||||
private async getOrganizationEncryptedExport(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<string> {
|
||||
const collections: Collection[] = [];
|
||||
const ciphers: Cipher[] = [];
|
||||
|
||||
@@ -215,7 +220,7 @@ export class OrganizationVaultExportService
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.BuildEncryptedExport(organizationId, collections, ciphers);
|
||||
return this.BuildEncryptedExport(userId, organizationId, collections, ciphers);
|
||||
}
|
||||
|
||||
private async getDecryptedManagedExport(
|
||||
@@ -295,16 +300,21 @@ export class OrganizationVaultExportService
|
||||
!this.restrictedItemTypesService.isCipherRestricted(f, restrictions),
|
||||
);
|
||||
|
||||
return this.BuildEncryptedExport(organizationId, encCollections, encCiphers);
|
||||
return this.BuildEncryptedExport(activeUserId, organizationId, encCollections, encCiphers);
|
||||
}
|
||||
|
||||
private async BuildEncryptedExport(
|
||||
activeUserId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
collections: Collection[],
|
||||
ciphers: Cipher[],
|
||||
): Promise<string> {
|
||||
const orgKey = await this.keyService.getOrgKey(organizationId);
|
||||
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), orgKey);
|
||||
const orgKeys = await firstValueFrom(this.keyService.orgKeys$(activeUserId));
|
||||
const keyForEncryption: SymmetricCryptoKey = orgKeys?.[organizationId];
|
||||
if (keyForEncryption == null) {
|
||||
throw new Error("No encryption key found for organization");
|
||||
}
|
||||
const encKeyValidation = await this.encryptService.encryptString(newGuid(), keyForEncryption);
|
||||
|
||||
const jsonDoc: BitwardenEncryptedOrgJsonExport = {
|
||||
encrypted: true,
|
||||
|
||||
@@ -5,12 +5,10 @@ import { Component, effect, input } from "@angular/core";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc/rxjs-operators";
|
||||
import { CalloutModule } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
@@ -30,6 +28,8 @@ export class ExportScopeCalloutComponent {
|
||||
readonly organizationId = input<string>();
|
||||
/* Optional export format, determines which individual export description to display */
|
||||
readonly exportFormat = input<string>();
|
||||
/* The description key to use for organizational exports */
|
||||
readonly orgExportDescription = input<string>();
|
||||
|
||||
constructor(
|
||||
protected organizationService: OrganizationService,
|
||||
@@ -37,35 +37,45 @@ export class ExportScopeCalloutComponent {
|
||||
) {
|
||||
effect(async () => {
|
||||
this.show = false;
|
||||
await this.getScopeMessage(this.organizationId(), this.exportFormat());
|
||||
await this.getScopeMessage(
|
||||
this.organizationId(),
|
||||
this.exportFormat(),
|
||||
this.orgExportDescription(),
|
||||
);
|
||||
this.show = true;
|
||||
});
|
||||
}
|
||||
|
||||
private async getScopeMessage(organizationId: string, exportFormat: string): Promise<void> {
|
||||
private async getScopeMessage(
|
||||
organizationId: string,
|
||||
exportFormat: string,
|
||||
orgExportDescription: string,
|
||||
): Promise<void> {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.scopeConfig =
|
||||
organizationId != null
|
||||
? {
|
||||
title: "exportingOrganizationVaultTitle",
|
||||
description: "exportingOrganizationVaultDesc",
|
||||
scopeIdentifier: (
|
||||
await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(organizationId)),
|
||||
)
|
||||
).name,
|
||||
}
|
||||
: {
|
||||
title: "exportingPersonalVaultTitle",
|
||||
description:
|
||||
exportFormat == "zip"
|
||||
? "exportingIndividualVaultWithAttachmentsDescription"
|
||||
: "exportingIndividualVaultDescription",
|
||||
scopeIdentifier: await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
),
|
||||
};
|
||||
|
||||
if (organizationId != null) {
|
||||
// exporting from organizational vault
|
||||
const org = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
|
||||
);
|
||||
|
||||
this.scopeConfig = {
|
||||
title: "exportingOrganizationVaultTitle",
|
||||
description: orgExportDescription,
|
||||
scopeIdentifier: org?.name ?? "",
|
||||
};
|
||||
} else {
|
||||
this.scopeConfig = {
|
||||
// exporting from individual vault
|
||||
title: "exportingPersonalVaultTitle",
|
||||
description:
|
||||
exportFormat === "zip"
|
||||
? "exportingIndividualVaultWithAttachmentsDescription"
|
||||
: "exportingIndividualVaultDescription",
|
||||
scopeIdentifier:
|
||||
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ??
|
||||
"",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<tools-export-scope-callout
|
||||
[organizationId]="organizationId"
|
||||
[exportFormat]="format"
|
||||
[orgExportDescription]="orgExportDescription"
|
||||
></tools-export-scope-callout>
|
||||
|
||||
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
|
||||
@@ -19,10 +20,10 @@
|
||||
[label]="'myVault' | i18n"
|
||||
value="myVault"
|
||||
icon="bwi-user"
|
||||
*ngIf="!(organizationDataOwnershipPolicy$ | async)"
|
||||
*ngIf="!(organizationDataOwnershipPolicyAppliesToUser$ | async)"
|
||||
/>
|
||||
<bit-option
|
||||
*ngFor="let o of organizations$ | async"
|
||||
*ngFor="let o of organizations"
|
||||
[value]="o.id"
|
||||
[label]="o.name"
|
||||
icon="bwi-business"
|
||||
|
||||
@@ -10,14 +10,20 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
Optional,
|
||||
} from "@angular/core";
|
||||
import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
@@ -36,10 +42,13 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ClientType, EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { pin } from "@bitwarden/common/tools/rx";
|
||||
@@ -84,11 +93,9 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
|
||||
],
|
||||
})
|
||||
export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
private _organizationId: OrganizationId | undefined;
|
||||
|
||||
get organizationId(): OrganizationId | undefined {
|
||||
return this._organizationId;
|
||||
}
|
||||
private _organizationId$ = new BehaviorSubject<OrganizationId | undefined>(undefined);
|
||||
private createDefaultLocationFlagEnabled$: Observable<boolean>;
|
||||
private _showExcludeMyItems = false;
|
||||
|
||||
/**
|
||||
* Enables the hosting control to pass in an organizationId
|
||||
@@ -96,29 +103,57 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
*/
|
||||
@Input() set organizationId(value: OrganizationId | string | undefined) {
|
||||
if (Utils.isNullOrEmpty(value)) {
|
||||
this._organizationId = undefined;
|
||||
this._organizationId$.next(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isId<OrganizationId>(value)) {
|
||||
this._organizationId = undefined;
|
||||
this._organizationId$.next(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
this._organizationId = value;
|
||||
this._organizationId$.next(value);
|
||||
|
||||
getUserId(this.accountService.activeAccount$)
|
||||
.pipe(
|
||||
switchMap((userId) =>
|
||||
this.organizationService.organizations$(userId).pipe(getById(this._organizationId)),
|
||||
),
|
||||
switchMap((userId) => this.organizationService.organizations$(userId).pipe(getById(value))),
|
||||
)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((organization) => {
|
||||
this._organizationId = organization?.id;
|
||||
this._organizationId$.next(organization?.id);
|
||||
});
|
||||
}
|
||||
|
||||
get organizationId(): OrganizationId | undefined {
|
||||
return this._organizationId$.value;
|
||||
}
|
||||
|
||||
get showExcludeMyItems(): boolean {
|
||||
return this._showExcludeMyItems;
|
||||
}
|
||||
|
||||
get orgExportDescription(): string {
|
||||
if (!this._showExcludeMyItems) {
|
||||
return "exportingOrganizationVaultDesc";
|
||||
}
|
||||
return this.isAdminConsoleContext
|
||||
? "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc"
|
||||
: "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc";
|
||||
}
|
||||
|
||||
private get isAdminConsoleContext(): boolean {
|
||||
const isWeb = this.platformUtilsService.getClientType?.() === ClientType.Web;
|
||||
if (!isWeb || !this.router) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = this.router.url ?? "";
|
||||
return url.includes("/organizations/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosting control also needs a bitSubmitDirective (on the Submit button) which calls this components {@link submit}-method.
|
||||
* This components formState (loading/disabled) is emitted back up to the hosting component so for example the Submit button can be enabled/disabled and show loading state.
|
||||
@@ -143,7 +178,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
/**
|
||||
* Emits when the creation and download of the export-file have succeeded
|
||||
* - Emits an undefined when exporting from an individual vault
|
||||
* - Emits the organizationId when exporting from an organizationl vault
|
||||
* - Emits the organizationId when exporting from an organizational vault
|
||||
* */
|
||||
@Output()
|
||||
onSuccessfulExport = new EventEmitter<OrganizationId | undefined>();
|
||||
@@ -162,7 +197,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
disablePersonalVaultExportPolicy$: Observable<boolean>;
|
||||
organizationDataOwnershipPolicy$: Observable<boolean>;
|
||||
// detects if policy is enabled and applies to the user, admins are exempted
|
||||
organizationDataOwnershipPolicyAppliesToUser$: Observable<boolean>;
|
||||
// detects if policy is enabled regardless of admin exemption
|
||||
organizationDataOwnershipPolicyEnabledForOrg$: Observable<boolean>;
|
||||
|
||||
exportForm = this.formBuilder.group({
|
||||
vaultSelector: [
|
||||
@@ -203,14 +241,46 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
protected organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private collectionService: CollectionService,
|
||||
private configService: ConfigService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
@Optional() private router?: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// Setup subscription to emit when this form is enabled/disabled
|
||||
this.observeFeatureFlags();
|
||||
this.observeFormState();
|
||||
this.observePolicyStatus();
|
||||
this.observeFormSelections();
|
||||
|
||||
// order is important below this line
|
||||
this.observeMyItemsExclusionCriteria();
|
||||
this.observeValidatorAdjustments();
|
||||
this.setupPasswordGeneration();
|
||||
|
||||
if (this.organizationId) {
|
||||
// organization vault export
|
||||
this.initOrganizationOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
// individual vault export
|
||||
this.initIndividual();
|
||||
this.setupPolicyBasedFormState();
|
||||
}
|
||||
|
||||
private observeFeatureFlags(): void {
|
||||
this.createDefaultLocationFlagEnabled$ = from(
|
||||
this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
}
|
||||
|
||||
private observeFormState(): void {
|
||||
this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
|
||||
this.formDisabled.emit(c === "DISABLED");
|
||||
});
|
||||
}
|
||||
|
||||
private observePolicyStatus(): void {
|
||||
this.disablePersonalVaultExportPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
@@ -218,13 +288,42 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
),
|
||||
);
|
||||
|
||||
this.organizationDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
// when true, html template will hide "My Vault" option in vault selector drop down
|
||||
this.organizationDataOwnershipPolicyAppliesToUser$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
),
|
||||
);
|
||||
|
||||
/*
|
||||
Determines how organization exports are described in the callout.
|
||||
Admins are exempted from organization data ownership policy,
|
||||
and so this needs to determine if the policy is enabled for the org, not if it applies to the user.
|
||||
*/
|
||||
this.organizationDataOwnershipPolicyEnabledForOrg$ = combineLatest([
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
this._organizationId$,
|
||||
]).pipe(
|
||||
switchMap(([userId, organizationId]) => {
|
||||
if (!organizationId || !userId) {
|
||||
return of(false);
|
||||
}
|
||||
return this.policyService.policies$(userId).pipe(
|
||||
map((policies) => {
|
||||
const policy = policies?.find(
|
||||
(p) =>
|
||||
p.type === PolicyType.OrganizationDataOwnership &&
|
||||
p.organizationId === organizationId,
|
||||
);
|
||||
return policy?.enabled ?? false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private observeFormSelections(): void {
|
||||
this.exportForm.controls.vaultSelector.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
@@ -236,15 +335,50 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine value of showExcludeMyItems. Returns true when:
|
||||
* CreateDefaultLocation feature flag is on
|
||||
* AND organizationDataOwnershipPolicy is enabled for the selected organization
|
||||
* AND a valid OrganizationId is present (not exporting from individual vault)
|
||||
*/
|
||||
private observeMyItemsExclusionCriteria(): void {
|
||||
combineLatest({
|
||||
createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$,
|
||||
organizationDataOwnershipPolicyEnabledForOrg:
|
||||
this.organizationDataOwnershipPolicyEnabledForOrg$,
|
||||
organizationId: this._organizationId$,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(
|
||||
({
|
||||
createDefaultLocationFlagEnabled,
|
||||
organizationDataOwnershipPolicyEnabledForOrg,
|
||||
organizationId,
|
||||
}) => {
|
||||
if (!createDefaultLocationFlagEnabled || !organizationId) {
|
||||
this._showExcludeMyItems = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Setup validator adjustments based on format and encryption type changes
|
||||
private observeValidatorAdjustments(): void {
|
||||
merge(
|
||||
this.exportForm.get("format").valueChanges,
|
||||
this.exportForm.get("fileEncryptionType").valueChanges,
|
||||
)
|
||||
.pipe(startWith(0), takeUntil(this.destroy$))
|
||||
.subscribe(() => this.adjustValidators());
|
||||
}
|
||||
|
||||
// Wire up the password generation for the password-protected export
|
||||
// Wire up the password generation for password-protected exports
|
||||
private setupPasswordGeneration(): void {
|
||||
const account$ = this.accountService.activeAccount$.pipe(
|
||||
pin({
|
||||
name() {
|
||||
@@ -255,6 +389,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
this.generatorService
|
||||
.generate$({ on$: this.onGenerate$, account$ })
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -264,23 +399,29 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
confirmFilePassword: generated.credential,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.organizationId) {
|
||||
this.organizations$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.organizationService
|
||||
.memberOrganizations$(userId)
|
||||
.pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))),
|
||||
),
|
||||
);
|
||||
this.exportForm.controls.vaultSelector.patchValue(this.organizationId);
|
||||
this.exportForm.controls.vaultSelector.disable();
|
||||
/*
|
||||
Initialize component for organization only export
|
||||
Hides "My Vault" option by returning immediately
|
||||
*/
|
||||
private initOrganizationOnly(): void {
|
||||
this.organizations$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.organizationService
|
||||
.memberOrganizations$(userId)
|
||||
.pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))),
|
||||
),
|
||||
);
|
||||
this.exportForm.controls.vaultSelector.patchValue(this.organizationId);
|
||||
this.exportForm.controls.vaultSelector.disable();
|
||||
|
||||
this.onlyManagedCollections = false;
|
||||
return;
|
||||
}
|
||||
this.onlyManagedCollections = false;
|
||||
}
|
||||
|
||||
// Initialize component to support individual and organizational exports
|
||||
private initIndividual(): void {
|
||||
this.organizations$ = this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
@@ -296,18 +437,18 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
const managedCollectionsOrgIds = new Set(
|
||||
collections.filter((c) => c.manage).map((c) => c.organizationId),
|
||||
);
|
||||
// Filter organizations that exist in managedCollectionsOrgIds
|
||||
const filteredOrgs = memberOrganizations.filter((org) =>
|
||||
managedCollectionsOrgIds.has(org.id),
|
||||
);
|
||||
// Sort the filtered organizations based on the name
|
||||
return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private setupPolicyBasedFormState(): void {
|
||||
combineLatest([
|
||||
this.disablePersonalVaultExportPolicy$,
|
||||
this.organizationDataOwnershipPolicy$,
|
||||
this.organizationDataOwnershipPolicyAppliesToUser$,
|
||||
this.organizations$,
|
||||
])
|
||||
.pipe(
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
</bit-callout>
|
||||
|
||||
<bit-callout *ngIf="hasLoginUri && hadPendingChangePasswordTask" type="warning" [title]="''">
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
|
||||
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
|
||||
Reference in New Issue
Block a user