1
0
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:
Tom
2025-10-02 09:37:04 -04:00
committed by GitHub
263 changed files with 9604 additions and 2607 deletions

View File

@@ -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({

View File

@@ -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";

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,9 @@
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
export class DefaultNewDeviceVerificationComponentService
implements NewDeviceVerificationComponentService
{
showBackButton() {
return true;
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -0,0 +1 @@
export * from "./send-token.service";

View File

@@ -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>;
}

View File

@@ -0,0 +1,4 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./send-access-token";

View File

@@ -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);
});
});

View 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);
}
}

View File

@@ -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);
}
});

View File

@@ -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,
};
}
}
}

View File

@@ -0,0 +1 @@
export * from "./default-send-token.service";

View File

@@ -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);
},
},
);

View File

@@ -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 };

View 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";

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendOtp = Opaque<string, "SendOtp">;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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");
});
});
});

View File

@@ -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";
}
});
}

View File

@@ -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.

View File

@@ -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: "",
},
};

View File

@@ -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",
]
: [],
);
}

View File

@@ -23,6 +23,7 @@ export default {
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
});
},
},

View File

@@ -0,0 +1 @@
export * from "./tooltip.directive";

View 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"],
},
];

View 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;
}

View 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>

View 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);
}

View 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 },
);
}
}

View 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} />

View 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);
});
});

View 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>
`,
}),
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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 isnt 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);

View File

@@ -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");

View File

@@ -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,

View File

@@ -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)))) ??
"",
};
}
}
}

View File

@@ -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"

View File

@@ -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(

View File

@@ -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>