mirror of
https://github.com/bitwarden/browser
synced 2026-02-22 12:24:01 +00:00
Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval
This commit is contained in:
@@ -17,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
@@ -114,6 +115,8 @@ export class PremiumUpgradeDialogComponent {
|
||||
* @returns A dialog reference object
|
||||
*/
|
||||
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
|
||||
return dialogService.open(PremiumUpgradeDialogComponent);
|
||||
return dialogService.open(PremiumUpgradeDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se
|
||||
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import { UnsupportedActionsService } from "@bitwarden/common/platform/actions/unsupported-actions.service";
|
||||
import { IpcSessionRepository } from "@bitwarden/common/platform/ipc";
|
||||
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||
@@ -1091,7 +1092,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: MasterPasswordUnlockService,
|
||||
useClass: DefaultMasterPasswordUnlockService,
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorServiceAbstraction,
|
||||
@@ -1454,7 +1455,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
@@ -1749,6 +1750,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: IpcSessionRepository,
|
||||
useClass: IpcSessionRepository,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: NoopPremiumInterestStateService,
|
||||
|
||||
@@ -37,6 +37,7 @@ export const NudgeType = {
|
||||
NewNoteItemStatus: "new-note-item-status",
|
||||
NewSshItemStatus: "new-ssh-item-status",
|
||||
GeneratorNudgeStatus: "generator-nudge-status",
|
||||
PremiumUpgrade: "premium-upgrade",
|
||||
} as const;
|
||||
|
||||
export type NudgeType = UnionOfValues<typeof NudgeType>;
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Component, Inject } from "@angular/core";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type FingerprintDialogData = {
|
||||
fingerprint: string[];
|
||||
@@ -19,6 +25,9 @@ export class FingerprintDialogComponent {
|
||||
constructor(@Inject(DIALOG_DATA) protected data: FingerprintDialogData) {}
|
||||
|
||||
static open(dialogService: DialogService, data: FingerprintDialogData) {
|
||||
return dialogService.open(FingerprintDialogComponent, { data });
|
||||
return dialogService.open(FingerprintDialogComponent, {
|
||||
data,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
@@ -31,6 +32,12 @@ import { PasswordInputResult } from "../../input-password/password-input-result"
|
||||
|
||||
import { RegistrationFinishService } from "./registration-finish.service";
|
||||
|
||||
const MarketingInitiative = Object.freeze({
|
||||
Premium: "premium",
|
||||
} as const);
|
||||
|
||||
type MarketingInitiative = (typeof MarketingInitiative)[keyof typeof MarketingInitiative];
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -46,6 +53,12 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
submitting = false;
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* Indicates that the user is coming from a marketing page designed to streamline
|
||||
* users who intend to setup a premium subscription after registration.
|
||||
*/
|
||||
premiumInterest = false;
|
||||
|
||||
// Note: this token is the email verification token. When it is supplied as a query param,
|
||||
// it either comes from the email verification email or, if email verification is disabled server side
|
||||
// via global settings, it comes directly from the registration-start component directly.
|
||||
@@ -79,6 +92,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private premiumInterestStateService: PremiumInterestStateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -126,6 +140,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
this.providerInviteToken = qParams.providerInviteToken;
|
||||
this.providerUserId = qParams.providerUserId;
|
||||
}
|
||||
|
||||
if (qParams.fromMarketing != null && qParams.fromMarketing === MarketingInitiative.Premium) {
|
||||
this.premiumInterest = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async initOrgInviteFlowIfPresent(): Promise<boolean> {
|
||||
@@ -190,6 +208,13 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
|
||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||
|
||||
if (this.premiumInterest) {
|
||||
await this.premiumInterestStateService.setPremiumInterest(
|
||||
authenticationResult.userId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/vault"]);
|
||||
} catch (e) {
|
||||
// If login errors, redirect to login page per product. Don't show error
|
||||
|
||||
@@ -20,4 +20,5 @@ export enum PolicyType {
|
||||
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
|
||||
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
|
||||
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
|
||||
BlockClaimedDomainAccountCreation = 19, // Prevents users from creating personal accounts using email addresses from verified domains
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("Organization", () => {
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
useActivateAutofillPolicy: false,
|
||||
useAutomaticUserConfirmation: false,
|
||||
selfHost: false,
|
||||
usersGetPremium: false,
|
||||
seats: 10,
|
||||
@@ -179,4 +180,118 @@ describe("Organization", () => {
|
||||
expect(organization.canManageDeviceApprovals).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEnableAutoConfirmPolicy", () => {
|
||||
it("should return false when user cannot manage users or policies", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.manageUsers = false;
|
||||
data.permissions.managePolicies = false;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user can manage users but useAutomaticUserConfirmation is false", () => {
|
||||
data.type = OrganizationUserType.Admin;
|
||||
data.useAutomaticUserConfirmation = false;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user has manageUsers permission but useAutomaticUserConfirmation is false", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.manageUsers = true;
|
||||
data.useAutomaticUserConfirmation = false;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user can manage policies but useAutomaticUserConfirmation is false", () => {
|
||||
data.type = OrganizationUserType.Admin;
|
||||
data.usePolicies = true;
|
||||
data.useAutomaticUserConfirmation = false;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user has managePolicies permission but usePolicies is false", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.managePolicies = true;
|
||||
data.usePolicies = false;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when admin has useAutomaticUserConfirmation enabled", () => {
|
||||
data.type = OrganizationUserType.Admin;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when owner has useAutomaticUserConfirmation enabled", () => {
|
||||
data.type = OrganizationUserType.Owner;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user has manageUsers permission and useAutomaticUserConfirmation is enabled", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.manageUsers = true;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user has managePolicies permission, usePolicies is true, and useAutomaticUserConfirmation is enabled", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.managePolicies = true;
|
||||
data.usePolicies = true;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user has both manageUsers and managePolicies permissions with useAutomaticUserConfirmation enabled", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.permissions.manageUsers = true;
|
||||
data.permissions.managePolicies = true;
|
||||
data.usePolicies = true;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when provider user has useAutomaticUserConfirmation enabled", () => {
|
||||
data.type = OrganizationUserType.Owner;
|
||||
data.isProviderUser = true;
|
||||
data.useAutomaticUserConfirmation = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,6 +310,14 @@ export class Organization {
|
||||
return this.isAdmin || this.permissions.manageResetPassword;
|
||||
}
|
||||
|
||||
get canEnableAutoConfirmPolicy() {
|
||||
return (
|
||||
(this.canManageUsers || this.canManagePolicies) &&
|
||||
this.useAutomaticUserConfirmation &&
|
||||
!this.isProviderUser
|
||||
);
|
||||
}
|
||||
|
||||
get canManageDeviceApprovals() {
|
||||
return (
|
||||
(this.isAdmin || this.permissions.manageResetPassword) &&
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 the access token found in session storage is expired, then it returns a {@link TryGetSendAccessTokenError} expired error and clears the token from storage so that a subsequent call can attempt to retrieve a new token.
|
||||
* 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.
|
||||
|
||||
@@ -25,6 +25,10 @@ export abstract class BillingApiServiceAbstraction {
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNextSelfHost(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;
|
||||
|
||||
@@ -62,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadataVNextSelfHost(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/vnext/self-host/metadata",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
||||
const r = await this.apiService.send("GET", "/plans", null, true, true);
|
||||
return new ListResponse(r, PlanResponse);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
@@ -15,6 +16,7 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
let service: DefaultOrganizationMetadataService;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
|
||||
const mockOrganizationId = newGuid() as OrganizationId;
|
||||
@@ -33,11 +35,17 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
|
||||
platformUtilsService.isSelfHost.mockReturnValue(false);
|
||||
|
||||
service = new DefaultOrganizationMetadataService(billingApiService, configService);
|
||||
service = new DefaultOrganizationMetadataService(
|
||||
billingApiService,
|
||||
configService,
|
||||
platformUtilsService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -142,6 +150,24 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
expect(result3).toEqual(mockResponse1);
|
||||
expect(result4).toEqual(mockResponse2);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
|
||||
platformUtilsService.isSelfHost.mockReturnValue(true);
|
||||
const mockResponse = createMockMetadataResponse(true, 25);
|
||||
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
|
||||
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareReplay behavior", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
@@ -17,6 +18,7 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
|
||||
|
||||
@@ -67,7 +69,9 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
||||
featureFlagEnabled: boolean,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
return featureFlagEnabled
|
||||
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
|
||||
? this.platformUtilsService.isSelfHost()
|
||||
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
|
||||
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
|
||||
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "block-claimed-domain-account-creation",
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
@@ -91,6 +92,7 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
@@ -7,7 +7,21 @@ export abstract class MasterPasswordUnlockService {
|
||||
* Unlocks the user's account using the master password.
|
||||
* @param masterPassword The master password provided by the user.
|
||||
* @param userId The ID of the active user.
|
||||
* @throws If the master password provided is null/undefined/empty.
|
||||
* @throws If the userId provided is null/undefined.
|
||||
* @throws if the masterPasswordUnlockData for the user is not found.
|
||||
* @throws If unwrapping the user key fails.
|
||||
* @returns the user's decrypted userKey.
|
||||
*/
|
||||
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* For the given master password and user ID, verifies whether the user can decrypt their user key stored in state.
|
||||
* @param masterPassword The master password provided by the user.
|
||||
* @param userId The ID of the active user.
|
||||
* @throws If the master password provided is null/undefined/empty.
|
||||
* @throws If the userId provided is null/undefined.
|
||||
* @returns true if the userKey can be decrypted, false otherwise.
|
||||
*/
|
||||
abstract proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { of } from "rxjs";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { CryptoError } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
@@ -23,6 +25,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const mockUserId = newGuid() as UserId;
|
||||
@@ -41,8 +44,9 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
beforeEach(() => {
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
|
||||
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService, logService);
|
||||
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||
of(mockMasterPasswordUnlockData),
|
||||
@@ -73,7 +77,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided master password is %s",
|
||||
"throws when the provided userID is %s",
|
||||
async (userId) => {
|
||||
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
|
||||
"User ID is required",
|
||||
@@ -151,4 +155,90 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("proofOfDecryption", () => {
|
||||
test.each([null as unknown as string, undefined as unknown as string, ""])(
|
||||
"throws when the provided master password is %s",
|
||||
async (masterPassword) => {
|
||||
await expect(sut.proofOfDecryption(masterPassword, mockUserId)).rejects.toThrow(
|
||||
"Master password is required",
|
||||
);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided userID is %s",
|
||||
async (userId) => {
|
||||
await expect(sut.proofOfDecryption(mockMasterPassword, userId)).rejects.toThrow(
|
||||
"User ID is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns false when the user doesn't have masterPasswordUnlockData", async () => {
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${mockUserId} returning false.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when the master password is correct", async () => {
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the master password is incorrect", async () => {
|
||||
const error = new Error("Incorrect password") as CryptoError;
|
||||
error.name = "CryptoError";
|
||||
error.variant = "InvalidKey";
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
expect(logService.debug).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${mockUserId} returning false: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when a generic error occurs", async () => {
|
||||
const error = new Error("Generic error");
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${mockUserId} returning false: ${error}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { firstValueFrom } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { isCryptoError } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
@@ -14,6 +16,7 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
|
||||
constructor(
|
||||
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private readonly keyService: KeyService,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
|
||||
@@ -37,6 +40,43 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
|
||||
return userKey;
|
||||
}
|
||||
|
||||
async proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean> {
|
||||
this.validateInput(masterPassword, userId);
|
||||
|
||||
try {
|
||||
const masterPasswordUnlockData = await firstValueFrom(
|
||||
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||
);
|
||||
|
||||
if (masterPasswordUnlockData == null) {
|
||||
this.logService.warning(
|
||||
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${userId} returning false.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
masterPasswordUnlockData,
|
||||
);
|
||||
|
||||
return userKey != null;
|
||||
} catch (error) {
|
||||
// masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData is expected to throw if the password is incorrect.
|
||||
// Currently this throws CryptoError:InvalidKey if decrypting the user key fails at all.
|
||||
if (isCryptoError(error) && error.variant === "InvalidKey") {
|
||||
this.logService.debug(
|
||||
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${userId} returning false: ${error}`,
|
||||
);
|
||||
} else {
|
||||
this.logService.error(
|
||||
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${userId} returning false: ${error}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(masterPassword: string, userId: UserId): void {
|
||||
if (masterPassword == null || masterPassword === "") {
|
||||
throw new Error("Master password is required");
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./ipc-message";
|
||||
export * from "./ipc.service";
|
||||
export * from "./ipc-session-repository";
|
||||
|
||||
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal file
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { FakeActiveUserAccessor, FakeStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { IpcSessionRepository } from "./ipc-session-repository";
|
||||
|
||||
describe("IpcSessionRepository", () => {
|
||||
const userId = "user-id" as UserId;
|
||||
let stateProvider!: FakeStateProvider;
|
||||
let repository!: IpcSessionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
stateProvider = new FakeStateProvider(new FakeActiveUserAccessor(userId));
|
||||
repository = new IpcSessionRepository(stateProvider);
|
||||
});
|
||||
|
||||
it("returns undefined when empty", async () => {
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves and retrieves a session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save("BrowserBackground", session);
|
||||
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("saves and retrieves a web session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save({ Web: { id: 9001 } }, session);
|
||||
|
||||
const result = await repository.get({ Web: { id: 9001 } });
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("removes a session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save("BrowserBackground", session);
|
||||
|
||||
await repository.remove("BrowserBackground");
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal file
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { Endpoint, IpcSessionRepository as SdkIpcSessionRepository } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { GlobalState, IPC_MEMORY, KeyDefinition, StateProvider } from "../state";
|
||||
|
||||
const IPC_SESSIONS = KeyDefinition.record<object, string>(IPC_MEMORY, "ipcSessions", {
|
||||
deserializer: (value: object) => value,
|
||||
});
|
||||
|
||||
/**
|
||||
* Implementation of SDK-defined repository interface/trait. Do not use directly.
|
||||
* All error handling is done by the caller (the SDK).
|
||||
* For more information see IPC docs.
|
||||
*
|
||||
* Interface uses `any` type as defined by the SDK until we get a concrete session type.
|
||||
*/
|
||||
export class IpcSessionRepository implements SdkIpcSessionRepository {
|
||||
private states: GlobalState<Record<string, any>>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.states = this.stateProvider.getGlobal(IPC_SESSIONS);
|
||||
}
|
||||
|
||||
get(endpoint: Endpoint): Promise<any | undefined> {
|
||||
return firstValueFrom(this.states.state$.pipe(map((s) => s?.[endpointToString(endpoint)])));
|
||||
}
|
||||
|
||||
async save(endpoint: Endpoint, session: any): Promise<void> {
|
||||
await this.states.update((s) => ({
|
||||
...s,
|
||||
[endpointToString(endpoint)]: session,
|
||||
}));
|
||||
}
|
||||
|
||||
async remove(endpoint: Endpoint): Promise<void> {
|
||||
await this.states.update((s) => {
|
||||
const newState = { ...s };
|
||||
delete newState[endpointToString(endpoint)];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function endpointToString(endpoint: Endpoint): string {
|
||||
if (typeof endpoint === "object" && "Web" in endpoint) {
|
||||
return `Web(${endpoint.Web.id})`;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
@@ -689,6 +689,32 @@ describe("Utils Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidUrlPatterns", () => {
|
||||
it("should return false if no invalid patterns are found", () => {
|
||||
const urlString = "https://www.example.com/api/my/account/status";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if an invalid pattern is found", () => {
|
||||
const urlString = "https://www.example.com/api/%2e%2e/secret";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if an invalid pattern is found in a param", () => {
|
||||
const urlString = "https://www.example.com/api/history?someToken=../secret";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl", () => {
|
||||
it("assumes a http protocol if no protocol is specified", () => {
|
||||
const urlString = "www.exampleapp.com.au:4000";
|
||||
|
||||
@@ -612,6 +612,55 @@ export class Utils {
|
||||
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an url checking against invalid patterns
|
||||
* @param url
|
||||
* @returns true if invalid patterns found, false if safe
|
||||
*/
|
||||
static invalidUrlPatterns(url: string): boolean {
|
||||
const invalidUrlPatterns = ["..", "%2e", "\\", "%5c"];
|
||||
|
||||
const decodedUrl = decodeURIComponent(url.toLocaleLowerCase());
|
||||
|
||||
// Check URL for invalidUrl patterns across entire URL
|
||||
if (invalidUrlPatterns.some((p) => decodedUrl.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for additional invalid patterns inside URL params
|
||||
if (decodedUrl.includes("?")) {
|
||||
const hasInvalidParams = this.validateQueryParameters(decodedUrl);
|
||||
if (hasInvalidParams) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates query parameters for additional invalid patterns
|
||||
* @param url - The URL containing query parameters
|
||||
* @returns true if invalid patterns found, false if safe
|
||||
*/
|
||||
private static validateQueryParameters(url: string): boolean {
|
||||
try {
|
||||
let queryString: string;
|
||||
|
||||
if (url.includes("?")) {
|
||||
queryString = url.split("?")[1];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paramInvalidPatterns = ["/", "%2f", "#", "%23"];
|
||||
|
||||
return paramInvalidPatterns.some((p) => queryString.includes(p));
|
||||
} catch (error) {
|
||||
throw new Error(`Error validating query parameters: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
|
||||
@@ -1588,8 +1588,16 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const pathParts = path.split("?");
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class SearchService {
|
||||
abstract isCipherSearching$: Observable<boolean>;
|
||||
abstract isSendSearching$: Observable<boolean>;
|
||||
|
||||
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
|
||||
|
||||
abstract clearIndex(userId: UserId): Promise<void>;
|
||||
|
||||
@@ -94,16 +94,16 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
this.lastName != null
|
||||
) {
|
||||
let name = "";
|
||||
if (this.title != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.title)) {
|
||||
name += this.title + " ";
|
||||
}
|
||||
if (this.firstName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.firstName)) {
|
||||
name += this.firstName + " ";
|
||||
}
|
||||
if (this.middleName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.middleName)) {
|
||||
name += this.middleName + " ";
|
||||
}
|
||||
if (this.lastName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.lastName)) {
|
||||
name += this.lastName;
|
||||
}
|
||||
return name.trim();
|
||||
@@ -130,14 +130,20 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
}
|
||||
|
||||
get fullAddressPart2(): string | undefined {
|
||||
if (this.city == null && this.state == null && this.postalCode == null) {
|
||||
const hasCity = !Utils.isNullOrWhitespace(this.city);
|
||||
const hasState = !Utils.isNullOrWhitespace(this.state);
|
||||
const hasPostalCode = !Utils.isNullOrWhitespace(this.postalCode);
|
||||
|
||||
if (!hasCity && !hasState && !hasPostalCode) {
|
||||
return undefined;
|
||||
}
|
||||
const city = this.city || "-";
|
||||
|
||||
const city = hasCity ? this.city : "-";
|
||||
const state = this.state;
|
||||
const postalCode = this.postalCode || "-";
|
||||
const postalCode = hasPostalCode ? this.postalCode : "-";
|
||||
|
||||
let addressPart2 = city;
|
||||
if (!Utils.isNullOrWhitespace(state)) {
|
||||
if (hasState) {
|
||||
addressPart2 += ", " + state;
|
||||
}
|
||||
addressPart2 += ", " + postalCode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as lunr from "lunr";
|
||||
import { Observable, firstValueFrom, map } from "rxjs";
|
||||
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
private readonly defaultSearchableMinLength: number = 2;
|
||||
private searchableMinLength: number = this.defaultSearchableMinLength;
|
||||
|
||||
private _isCipherSearching$ = new BehaviorSubject<boolean>(false);
|
||||
isCipherSearching$: Observable<boolean> = this._isCipherSearching$.asObservable();
|
||||
|
||||
private _isSendSearching$ = new BehaviorSubject<boolean>(false);
|
||||
isSendSearching$: Observable<boolean> = this._isSendSearching$.asObservable();
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
|
||||
ciphers: C[],
|
||||
): Promise<C[]> {
|
||||
this._isCipherSearching$.next(true);
|
||||
const results: C[] = [];
|
||||
const searchStartTime = performance.now();
|
||||
if (query != null) {
|
||||
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
|
||||
if (!(await this.isSearchable(userId, query))) {
|
||||
this._isCipherSearching$.next(false);
|
||||
return ciphers;
|
||||
}
|
||||
|
||||
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
// Fall back to basic search if index is not available
|
||||
const basicResults = this.searchCiphersBasic(ciphers, query);
|
||||
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
|
||||
this._isCipherSearching$.next(false);
|
||||
return basicResults;
|
||||
}
|
||||
|
||||
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
});
|
||||
}
|
||||
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
|
||||
this._isCipherSearching$.next(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
|
||||
searchSends(sends: SendView[], query: string) {
|
||||
this._isSendSearching$.next(true);
|
||||
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
|
||||
if (query === null) {
|
||||
this._isSendSearching$.next(false);
|
||||
return sends;
|
||||
}
|
||||
const sendsMatched: SendView[] = [];
|
||||
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
lowPriorityMatched.push(s);
|
||||
}
|
||||
});
|
||||
this._isSendSearching$.next(false);
|
||||
return sendsMatched.concat(lowPriorityMatched);
|
||||
}
|
||||
|
||||
|
||||
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { skeletonLoadingDelay } from "./skeleton-loading.operator";
|
||||
|
||||
describe("skeletonLoadingDelay", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false immediately when starting with false", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(false);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("waits 1 second before returning true when starting with true", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
expect(results).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([true]);
|
||||
});
|
||||
|
||||
it("cancels if source becomes false before show delay completes", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
source$.next(false);
|
||||
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("delays hiding if minimum display time has not elapsed", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
source$.next(false);
|
||||
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("handles rapid true->false->true transitions", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
source$.next(false);
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
source$.next(true);
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("allows for custom timings", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
source$.next(false);
|
||||
|
||||
jest.advanceTimersByTime(1999);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal file
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defer, Observable, of, timer } from "rxjs";
|
||||
import { map, switchMap, tap } from "rxjs/operators";
|
||||
|
||||
/**
|
||||
* RxJS operator that adds skeleton loading delay behavior.
|
||||
*
|
||||
* - Waits 1 second before showing (prevents flashing for quick loads)
|
||||
* - Ensures skeleton stays visible for at least 1 second once shown regardless of the source observable emissions
|
||||
* - After the minimum display time, if the source is still true, continues to emit true until the source becomes false
|
||||
* - False can only be emitted either:
|
||||
* - Immediately when the source emits false before the skeleton is shown
|
||||
* - After the minimum display time has passed once the skeleton is shown
|
||||
*/
|
||||
export function skeletonLoadingDelay(
|
||||
showDelay = 1000,
|
||||
minDisplayTime = 1000,
|
||||
): (source: Observable<boolean>) => Observable<boolean> {
|
||||
return (source: Observable<boolean>) => {
|
||||
return defer(() => {
|
||||
let skeletonShownAt: number | null = null;
|
||||
|
||||
return source.pipe(
|
||||
switchMap((shouldShow): Observable<boolean> => {
|
||||
if (shouldShow) {
|
||||
if (skeletonShownAt !== null) {
|
||||
return of(true); // Already shown, continue showing
|
||||
}
|
||||
|
||||
// Wait for delay, then mark the skeleton as shown and emit true
|
||||
return timer(showDelay).pipe(
|
||||
tap(() => {
|
||||
skeletonShownAt = Date.now();
|
||||
}),
|
||||
map(() => true),
|
||||
);
|
||||
} else {
|
||||
if (skeletonShownAt === null) {
|
||||
// Skeleton not shown yet, can emit false immediately
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// Skeleton shown, ensure minimum display time has passed
|
||||
const elapsedTime = Date.now() - skeletonShownAt;
|
||||
const remainingTime = Math.max(0, minDisplayTime - elapsedTime);
|
||||
|
||||
// Wait for remaining time to ensure minimum display time
|
||||
return timer(remainingTime).pipe(
|
||||
tap(() => {
|
||||
// Reset the shown timestamp
|
||||
skeletonShownAt = null;
|
||||
}),
|
||||
map(() => false),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -5,19 +5,19 @@ import { By } from "@angular/platform-browser";
|
||||
import { ButtonModule } from "./index";
|
||||
|
||||
describe("Button", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let testAppComponent: TestApp;
|
||||
let fixture: ComponentFixture<TestAppComponent>;
|
||||
let testAppComponent: TestAppComponent;
|
||||
let buttonDebugElement: DebugElement;
|
||||
let disabledButtonDebugElement: DebugElement;
|
||||
let linkDebugElement: DebugElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestApp],
|
||||
imports: [TestAppComponent],
|
||||
});
|
||||
|
||||
await TestBed.compileComponents();
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture = TestBed.createComponent(TestAppComponent);
|
||||
testAppComponent = fixture.debugElement.componentInstance;
|
||||
buttonDebugElement = fixture.debugElement.query(By.css("button"));
|
||||
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
|
||||
@@ -86,7 +86,7 @@ describe("Button", () => {
|
||||
`,
|
||||
imports: [ButtonModule],
|
||||
})
|
||||
class TestApp {
|
||||
class TestAppComponent {
|
||||
buttonType?: string;
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
class="tw-inline-flex tw-items-center tw-rounded-full tw-w-full tw-border-solid tw-border tw-gap-1.5 tw-group/chip-select"
|
||||
[ngClass]="{
|
||||
'tw-bg-text-muted hover:tw-bg-secondary-700 tw-text-contrast hover:!tw-border-secondary-700':
|
||||
selectedOption && !disabled,
|
||||
selectedOption && !disabled(),
|
||||
'tw-bg-transparent hover:tw-border-secondary-700 !tw-text-muted hover:tw-bg-secondary-100':
|
||||
!selectedOption && !disabled,
|
||||
'tw-bg-secondary-300 tw-text-muted tw-border-transparent': disabled,
|
||||
'tw-border-text-muted': !disabled,
|
||||
!selectedOption && !disabled(),
|
||||
'tw-bg-secondary-300 tw-text-muted tw-border-transparent': disabled(),
|
||||
'tw-border-text-muted': !disabled(),
|
||||
'tw-ring-2 tw-ring-primary-600 tw-ring-offset-1': focusVisibleWithin(),
|
||||
}"
|
||||
>
|
||||
@@ -17,11 +17,11 @@
|
||||
class="tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-ps-3 last:tw-pe-3 [&:not(:last-child)]:tw-pe-0 tw-truncate tw-text-[color:inherit] tw-text-[length:inherit]"
|
||||
data-fvw-target
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
'group-hover/chip-select:tw-text-secondary-700': !selectedOption && !disabled,
|
||||
'tw-cursor-not-allowed': disabled(),
|
||||
'group-hover/chip-select:tw-text-secondary-700': !selectedOption && !disabled(),
|
||||
}"
|
||||
[bitMenuTriggerFor]="menu"
|
||||
[disabled]="disabled"
|
||||
[disabled]="disabled()"
|
||||
[title]="label"
|
||||
#menuTrigger="menuTrigger"
|
||||
(click)="setMenuWidth()"
|
||||
@@ -45,10 +45,10 @@
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="'removeItem' | i18n: label"
|
||||
[disabled]="disabled"
|
||||
[disabled]="disabled()"
|
||||
class="tw-bg-transparent hover:tw-bg-hover-contrast tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast hover:disabled:tw-bg-transparent"
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
'tw-cursor-not-allowed': disabled(),
|
||||
}"
|
||||
(click)="clear()"
|
||||
>
|
||||
|
||||
486
libs/components/src/chip-select/chip-select.component.spec.ts
Normal file
486
libs/components/src/chip-select/chip-select.component.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { MenuTriggerForDirective } from "../menu";
|
||||
|
||||
import { ChipSelectComponent, ChipSelectOption } from "./chip-select.component";
|
||||
|
||||
const mockI18nService = {
|
||||
t: (key: string, ...args: string[]) => {
|
||||
if (key === "removeItem") {
|
||||
return `Remove ${args[0]}`;
|
||||
}
|
||||
if (key === "backTo") {
|
||||
return `Back to ${args[0]}`;
|
||||
}
|
||||
if (key === "viewItemsIn") {
|
||||
return `View items in ${args[0]}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
};
|
||||
|
||||
describe("ChipSelectComponent", () => {
|
||||
let component: ChipSelectComponent<string>;
|
||||
let fixture: ComponentFixture<TestAppComponent>;
|
||||
|
||||
const testOptions: ChipSelectOption<string>[] = [
|
||||
{ label: "Option 1", value: "opt1", icon: "bwi-folder" },
|
||||
{ label: "Option 2", value: "opt2" },
|
||||
{
|
||||
label: "Parent Option",
|
||||
value: "parent",
|
||||
children: [
|
||||
{ label: "Child 1", value: "child1" },
|
||||
{ label: "Child 2", value: "child2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getMenuTriggerDirective = () => {
|
||||
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
|
||||
return buttonDebugElement?.injector.get(MenuTriggerForDirective);
|
||||
};
|
||||
|
||||
const getBitMenuPanel = () => document.querySelector(".bit-menu-panel");
|
||||
|
||||
const getChipButton = () =>
|
||||
fixture.debugElement.query(By.css("[data-fvw-target]"))?.nativeElement as HTMLButtonElement;
|
||||
|
||||
const getClearButton = () =>
|
||||
fixture.debugElement.query(By.css('button[aria-label^="Remove"]'))
|
||||
?.nativeElement as HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestAppComponent, NoopAnimationsModule],
|
||||
providers: [{ provide: I18nService, useValue: mockI18nService }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestAppComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
component = fixture.debugElement.query(By.directive(ChipSelectComponent)).componentInstance;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("User-Facing Behavior", () => {
|
||||
it("should display placeholder text when no option is selected", () => {
|
||||
expect(getChipButton().textContent).toContain("Select an option");
|
||||
});
|
||||
|
||||
it("should display placeholder icon when no option is selected", () => {
|
||||
const icon = fixture.debugElement.query(By.css('i[aria-hidden="true"]'));
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should disable chip button when disabled", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.disabled.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getChipButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept fullWidth input", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.fullWidth.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.fullWidth()).toBe(true);
|
||||
});
|
||||
|
||||
it("should update available options when they change", () => {
|
||||
const newOptions: ChipSelectOption<string>[] = [
|
||||
{ label: "New Option 1", value: "new1" },
|
||||
{ label: "New Option 2", value: "new2" },
|
||||
];
|
||||
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.options.set(newOptions);
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const menuItems = Array.from(document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"));
|
||||
expect(menuItems.some((el) => el.textContent?.includes("New Option 1"))).toBe(true);
|
||||
expect(menuItems.some((el) => el.textContent?.includes("New Option 2"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Integration Behavior", () => {
|
||||
it("should display selected option when form control value is set", () => {
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = getChipButton();
|
||||
expect(button.textContent?.trim()).toContain("Option 1");
|
||||
});
|
||||
|
||||
it("should find and display nested option when form control value is set", () => {
|
||||
component.writeValue("child1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = getChipButton();
|
||||
expect(button.textContent?.trim()).toContain("Child 1");
|
||||
});
|
||||
|
||||
it("should clear selection when form control value is set to null", () => {
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().textContent).toContain("Option 1");
|
||||
|
||||
component.writeValue(null as any);
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().textContent).toContain("Select an option");
|
||||
});
|
||||
|
||||
it("should disable chip when form control is disabled", () => {
|
||||
expect(getChipButton().disabled).toBe(false);
|
||||
|
||||
component.setDisabledState(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getChipButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should respect both template and programmatic disabled states", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.disabled.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().disabled).toBe(true);
|
||||
|
||||
testApp.disabled.set(false);
|
||||
component.setDisabledState(true);
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().disabled).toBe(true);
|
||||
|
||||
component.setDisabledState(false);
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should integrate with Angular reactive forms", () => {
|
||||
const formControl = new FormControl<string>("opt1");
|
||||
component.registerOnChange((value) => formControl.setValue(value));
|
||||
component.writeValue(formControl.value);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["selectedOption"]?.value).toBe("opt1");
|
||||
});
|
||||
|
||||
it("should update form value when option is selected", () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
component.registerOnChange(onChangeSpy);
|
||||
|
||||
component.writeValue("opt2");
|
||||
component["onChange"]({ label: "Option 2", value: "opt2" });
|
||||
|
||||
expect(onChangeSpy).toHaveBeenCalledWith("opt2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Menu Behavior", () => {
|
||||
it("should open menu when chip button is clicked", () => {
|
||||
const chipButton = getChipButton();
|
||||
chipButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getBitMenuPanel()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close menu when backdrop is clicked", () => {
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
expect(getBitMenuPanel()).toBeTruthy();
|
||||
|
||||
const backdrop = document.querySelector(".cdk-overlay-backdrop") as HTMLElement;
|
||||
backdrop.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getBitMenuPanel()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not open menu when disabled", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.disabled.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getBitMenuPanel()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should focus first menu item when menu opens", async () => {
|
||||
const trigger = getMenuTriggerDirective();
|
||||
trigger.toggleMenu();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const menu = component.menu();
|
||||
expect(menu?.keyManager?.activeItemIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should not focus menu items during initialization (before menu opens)", () => {
|
||||
const menu = component.menu();
|
||||
expect(menu?.keyManager?.activeItemIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("should calculate and set menu width on open", () => {
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["menuWidth"]).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("should reset menu width when menu closes", () => {
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
component["menuWidth"] = 200;
|
||||
|
||||
getMenuTriggerDirective().toggleMenu();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["menuWidth"]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Option Selection", () => {
|
||||
it("should select option and notify form control", () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
component.registerOnChange(onChangeSpy);
|
||||
|
||||
const option = testOptions[0];
|
||||
component["selectOption"](option, new MouseEvent("click"));
|
||||
|
||||
expect(component["selectedOption"]).toEqual(option);
|
||||
expect(onChangeSpy).toHaveBeenCalledWith("opt1");
|
||||
});
|
||||
|
||||
it("should display selected option label in chip button", () => {
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = getChipButton();
|
||||
expect(button.textContent?.trim()).toContain("Option 1");
|
||||
});
|
||||
|
||||
it("should show clear button when option is selected", () => {
|
||||
expect(getClearButton()).toBeFalsy();
|
||||
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getClearButton()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should clear selection when clear button is clicked", () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
component.registerOnChange(onChangeSpy);
|
||||
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const clearButton = getClearButton();
|
||||
clearButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["selectedOption"]).toBeNull();
|
||||
expect(onChangeSpy).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("should display placeholder when no option is selected", () => {
|
||||
expect(component["selectedOption"]).toBeFalsy();
|
||||
expect(getChipButton().textContent).toContain("Select an option");
|
||||
});
|
||||
|
||||
it("should display option icon when selected", () => {
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const icon = fixture.debugElement.query(By.css('i[aria-hidden="true"]'));
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested Options Navigation", () => {
|
||||
it("should navigate to child options when parent is clicked", () => {
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const parentMenuItem = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"),
|
||||
).find((el) => el.textContent?.includes("Parent Option"));
|
||||
|
||||
expect(parentMenuItem).toBeTruthy();
|
||||
|
||||
parentMenuItem?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["renderedOptions"]?.value).toBe("parent");
|
||||
});
|
||||
|
||||
it("should show back navigation when in submenu", async () => {
|
||||
component.writeValue("child1");
|
||||
component["setOrResetRenderedOptions"]();
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const backButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"),
|
||||
).find((el) => el.textContent?.includes("Back to"));
|
||||
|
||||
expect(backButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should navigate back to parent menu", async () => {
|
||||
component.writeValue("child1");
|
||||
component["setOrResetRenderedOptions"]();
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const backButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"),
|
||||
).find((el) => el.textContent?.includes("Back to"));
|
||||
|
||||
expect(backButton).toBeTruthy();
|
||||
|
||||
backButton?.dispatchEvent(new MouseEvent("click"));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["renderedOptions"]?.value).toBeNull();
|
||||
});
|
||||
|
||||
it("should update rendered options when selected option has children", () => {
|
||||
component.writeValue("parent");
|
||||
component["setOrResetRenderedOptions"]();
|
||||
|
||||
expect(component["renderedOptions"]?.value).toBe("parent");
|
||||
});
|
||||
|
||||
it("should show parent menu if selected option has no children", () => {
|
||||
component.writeValue("child1");
|
||||
component["setOrResetRenderedOptions"]();
|
||||
|
||||
expect(component["renderedOptions"]?.value).toBe("parent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled State Behavior", () => {
|
||||
it("should disable clear button when disabled", () => {
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.disabled.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const clearButton = getClearButton();
|
||||
expect(clearButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Focus Management", () => {
|
||||
it("should track focus-visible-within state", () => {
|
||||
const chipButton = getChipButton();
|
||||
|
||||
chipButton.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["focusVisibleWithin"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("should clear focus-visible-within on focusout", () => {
|
||||
component["focusVisibleWithin"].set(true);
|
||||
|
||||
const chipButton = getChipButton();
|
||||
chipButton.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["focusVisibleWithin"]()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty options array", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.options.set([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.options()).toEqual([]);
|
||||
expect(component["rootTree"]?.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle options without icons", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.options.set([{ label: "No Icon Option", value: "no-icon" }]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.options()).toEqual([{ label: "No Icon Option", value: "no-icon" }]);
|
||||
});
|
||||
|
||||
it("should handle disabled options in menu", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.options.set([
|
||||
{ label: "Enabled Option", value: "enabled" },
|
||||
{ label: "Disabled Option", value: "disabled", disabled: true },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const disabledMenuItem = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"),
|
||||
).find((el) => el.textContent?.includes("Disabled Option"));
|
||||
|
||||
expect(disabledMenuItem?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: "test-app",
|
||||
template: `
|
||||
<bit-chip-select
|
||||
placeholderText="Select an option"
|
||||
placeholderIcon="bwi-filter"
|
||||
[options]="options()"
|
||||
[disabled]="disabled()"
|
||||
[fullWidth]="fullWidth()"
|
||||
/>
|
||||
`,
|
||||
imports: [ChipSelectComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestAppComponent {
|
||||
readonly options = signal<ChipSelectOption<string>[]>([
|
||||
{ label: "Option 1", value: "opt1", icon: "bwi-folder" },
|
||||
{ label: "Option 2", value: "opt2" },
|
||||
{
|
||||
label: "Parent Option",
|
||||
value: "parent",
|
||||
children: [
|
||||
{ label: "Child 1", value: "child1" },
|
||||
{ label: "Child 2", value: "child2" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
readonly disabled = signal(false);
|
||||
readonly fullWidth = signal(false);
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
booleanAttribute,
|
||||
inject,
|
||||
computed,
|
||||
effect,
|
||||
signal,
|
||||
input,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
|
||||
import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { MenuComponent, MenuItemDirective, MenuModule } from "../menu";
|
||||
import { MenuComponent, MenuItemComponent, MenuModule, MenuTriggerForDirective } from "../menu";
|
||||
import { Option } from "../select/option";
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
@@ -35,8 +34,6 @@ export type ChipSelectOption<T> = Option<T> & {
|
||||
/**
|
||||
* `<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-chip-select",
|
||||
templateUrl: "chip-select.component.html",
|
||||
@@ -48,13 +45,15 @@ export type ChipSelectOption<T> = Option<T> & {
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, AfterViewInit {
|
||||
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
readonly menu = viewChild(MenuComponent);
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChildren(MenuItemDirective) menuItems?: QueryList<MenuItemDirective>;
|
||||
readonly menuItems = viewChildren(MenuItemComponent);
|
||||
readonly chipSelectButton = viewChild<ElementRef<HTMLButtonElement>>("chipSelectButton");
|
||||
readonly menuTrigger = viewChild(MenuTriggerForDirective);
|
||||
|
||||
/** Text to show when there is no selected option */
|
||||
readonly placeholderText = input.required<string>();
|
||||
@@ -62,28 +61,20 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
/** Icon to show when there is no selected option or the selected option does not have an icon */
|
||||
readonly placeholderIcon = input<string>();
|
||||
|
||||
private _options: ChipSelectOption<T>[] = [];
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
/** The select options to render */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true })
|
||||
get options(): ChipSelectOption<T>[] {
|
||||
return this._options;
|
||||
}
|
||||
set options(value: ChipSelectOption<T>[]) {
|
||||
this._options = value;
|
||||
this.initializeRootTree(value);
|
||||
}
|
||||
readonly options = input.required<ChipSelectOption<T>[]>();
|
||||
|
||||
/** Disables the entire chip */
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Your application code writes to the input. This prevents migration.
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ transform: booleanAttribute }) disabled = false;
|
||||
/** Disables the entire chip (template input) */
|
||||
protected readonly disabledInput = input<boolean, unknown>(false, {
|
||||
alias: "disabled",
|
||||
transform: booleanAttribute,
|
||||
});
|
||||
|
||||
/** Disables the entire chip (programmatic control from CVA) */
|
||||
private readonly disabledState = signal(false);
|
||||
|
||||
/** Combined disabled state from both input and programmatic control */
|
||||
readonly disabled = computed(() => this.disabledInput() || this.disabledState());
|
||||
|
||||
/** Chip will stretch to full width of its container */
|
||||
readonly fullWidth = input<boolean, unknown>(undefined, { transform: booleanAttribute });
|
||||
@@ -106,11 +97,30 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
return ["tw-inline-block", this.fullWidth() ? "tw-w-full" : "tw-max-w-52"];
|
||||
}
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Tree constructed from `this.options` */
|
||||
private rootTree?: ChipSelectOption<T> | null;
|
||||
|
||||
constructor() {
|
||||
// Initialize the root tree whenever options change
|
||||
effect(() => {
|
||||
this.initializeRootTree(this.options());
|
||||
});
|
||||
|
||||
// Focus the first menu item when menuItems change (e.g., navigating submenus)
|
||||
effect(() => {
|
||||
// Trigger effect when menuItems changes
|
||||
const items = this.menuItems();
|
||||
const currentMenu = this.menu();
|
||||
const trigger = this.menuTrigger();
|
||||
// Note: `isOpen` is intentionally accessed outside signal tracking (via `trigger?.isOpen`)
|
||||
// to avoid re-focusing when the menu state changes. We only want to focus during
|
||||
// submenu navigation, not on initial open/close.
|
||||
if (items.length > 0 && trigger?.isOpen) {
|
||||
currentMenu?.keyManager?.setFirstItemActive();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Options that are currently displayed in the menu */
|
||||
protected renderedOptions?: ChipSelectOption<T> | null;
|
||||
|
||||
@@ -225,16 +235,6 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
this.renderedOptions = this.rootTree;
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
/**
|
||||
* menuItems will change when the user navigates into or out of a submenu. when that happens, we want to
|
||||
* direct their focus to the first item in the new menu
|
||||
*/
|
||||
this.menuItems?.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.menu()?.keyManager?.setFirstItemActive();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the width of the menu based on whichever is larger, the chip select width or the width of
|
||||
* the initially rendered options
|
||||
@@ -257,6 +257,9 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
writeValue(obj: T): void {
|
||||
this.selectedOption = this.findOption(this.rootTree, obj);
|
||||
this.setOrResetRenderedOptions();
|
||||
// OnPush components require manual change detection when writeValue() is called
|
||||
// externally by Angular forms, as the framework doesn't automatically trigger CD
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
@@ -271,7 +274,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
this.disabledState.set(isDisabled);
|
||||
}
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
|
||||
@@ -47,7 +47,7 @@ class StoryDialogComponent {
|
||||
}
|
||||
|
||||
openDialogNonDismissable() {
|
||||
this.dialogService.open(NonDismissableContent, {
|
||||
this.dialogService.open(NonDismissableContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
@@ -117,7 +117,7 @@ class StoryDialogContentComponent {
|
||||
`,
|
||||
imports: [DialogModule, ButtonModule],
|
||||
})
|
||||
class NonDismissableContent {
|
||||
class NonDismissableContentComponent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DIALOG_DATA,
|
||||
DialogCloseOptions,
|
||||
} from "@angular/cdk/dialog";
|
||||
import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import { ComponentType, GlobalPositionStrategy, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -17,6 +17,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
|
||||
|
||||
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
|
||||
import { SimpleDialogOptions } from "./simple-dialog/types";
|
||||
@@ -63,6 +64,68 @@ export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A responsive position strategy that adjusts the dialog position based on the screen size.
|
||||
*/
|
||||
class ResponsivePositionStrategy extends GlobalPositionStrategy {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* The previous breakpoint to avoid unnecessary updates.
|
||||
* `null` means no previous breakpoint has been set.
|
||||
*/
|
||||
private prevBreakpoint: "small" | "large" | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (typeof window !== "undefined") {
|
||||
this.abortController = new AbortController();
|
||||
this.updatePosition(); // Initial position update
|
||||
window.addEventListener("resize", this.updatePosition.bind(this), {
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
const isSmallScreen = !isAtOrLargerThanBreakpoint("md");
|
||||
const currentBreakpoint = isSmallScreen ? "small" : "large";
|
||||
if (this.prevBreakpoint === currentBreakpoint) {
|
||||
return; // No change in breakpoint, no need to update position
|
||||
}
|
||||
this.prevBreakpoint = currentBreakpoint;
|
||||
if (isSmallScreen) {
|
||||
this.bottom().centerHorizontally();
|
||||
} else {
|
||||
this.centerVertically().centerHorizontally();
|
||||
}
|
||||
this.apply();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position strategy that centers dialogs regardless of screen size.
|
||||
* Use this for simple dialogs and custom dialogs that should not use
|
||||
* the responsive bottom-sheet behavior on mobile.
|
||||
*
|
||||
* @example
|
||||
* dialogService.open(MyComponent, {
|
||||
* positionStrategy: new CenterPositionStrategy()
|
||||
* });
|
||||
*/
|
||||
export class CenterPositionStrategy extends GlobalPositionStrategy {
|
||||
constructor() {
|
||||
super();
|
||||
this.centerHorizontally().centerVertically();
|
||||
}
|
||||
}
|
||||
|
||||
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = true;
|
||||
|
||||
@@ -172,6 +235,7 @@ export class DialogService {
|
||||
const _config = {
|
||||
backdropClass: this.backDropClasses,
|
||||
scrollStrategy: this.defaultScrollStrategy,
|
||||
positionStrategy: config?.positionStrategy ?? new ResponsivePositionStrategy(),
|
||||
injector,
|
||||
...config,
|
||||
};
|
||||
@@ -226,6 +290,7 @@ export class DialogService {
|
||||
return this.open<boolean, SimpleDialogOptions>(SimpleConfigurableDialogComponent, {
|
||||
data: simpleDialogOptions,
|
||||
disableClose: simpleDialogOptions.disableClose,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl tw-shadow-lg']"
|
||||
@fadeIn
|
||||
[ngClass]="[
|
||||
width,
|
||||
isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
|
||||
]"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
HostBinding,
|
||||
inject,
|
||||
viewChild,
|
||||
input,
|
||||
booleanAttribute,
|
||||
ElementRef,
|
||||
DestroyRef,
|
||||
computed,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, switchMap } from "rxjs";
|
||||
@@ -21,7 +22,6 @@ import { SpinnerComponent } from "../../spinner";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrollableContent$ } from "../../utils/";
|
||||
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { fadeIn } from "../animations";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
@@ -31,9 +31,10 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
@Component({
|
||||
selector: "bit-dialog",
|
||||
templateUrl: "./dialog.component.html",
|
||||
animations: [fadeIn],
|
||||
host: {
|
||||
"[class]": "classes()",
|
||||
"(keydown.esc)": "handleEsc($event)",
|
||||
"(animationend)": "onAnimationEnd()",
|
||||
},
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -87,22 +88,34 @@ export class DialogComponent {
|
||||
*/
|
||||
readonly disablePadding = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Disable animations for the dialog.
|
||||
*/
|
||||
readonly disableAnimations = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Mark the dialog as loading which replaces the content with a spinner.
|
||||
*/
|
||||
readonly loading = input(false);
|
||||
|
||||
@HostBinding("class") get classes() {
|
||||
private readonly animationCompleted = signal(false);
|
||||
|
||||
protected readonly classes = computed(() => {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
return ["tw-flex", "tw-flex-col", "tw-w-screen"]
|
||||
.concat(
|
||||
this.width,
|
||||
this.dialogRef?.isDrawer
|
||||
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
|
||||
: ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"],
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
|
||||
const sizeClasses = this.dialogRef?.isDrawer
|
||||
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
|
||||
: ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"];
|
||||
|
||||
const animationClasses =
|
||||
this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer
|
||||
? []
|
||||
: this.dialogSize() === "small"
|
||||
? ["tw-animate-slide-down"]
|
||||
: ["tw-animate-slide-up", "md:tw-animate-slide-down"];
|
||||
|
||||
return [...baseClasses, this.width, ...sizeClasses, ...animationClasses];
|
||||
});
|
||||
|
||||
handleEsc(event: Event) {
|
||||
if (!this.dialogRef?.disableClose) {
|
||||
@@ -124,4 +137,8 @@ export class DialogComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAnimationEnd() {
|
||||
this.animationCompleted.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export default {
|
||||
args: {
|
||||
loading: false,
|
||||
dialogSize: "small",
|
||||
disableAnimations: true,
|
||||
},
|
||||
argTypes: {
|
||||
_disablePadding: {
|
||||
@@ -71,6 +72,9 @@ export default {
|
||||
defaultValue: "default",
|
||||
},
|
||||
},
|
||||
disableAnimations: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
@@ -86,7 +90,7 @@ export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-dialog [dialogSize]="dialogSize" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<bit-dialog [dialogSize]="dialogSize" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<ng-container bitDialogTitle>
|
||||
<span bitBadge variant="success">Foobar</span>
|
||||
</ng-container>
|
||||
@@ -158,7 +162,7 @@ export const ScrollingContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-dialog title="Scrolling Example" [background]="background" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<bit-dialog title="Scrolling Example" [background]="background" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.<br />
|
||||
<ng-container *ngFor="let _ of [].constructor(100)">
|
||||
@@ -175,6 +179,7 @@ export const ScrollingContent: Story = {
|
||||
}),
|
||||
args: {
|
||||
dialogSize: "small",
|
||||
disableAnimations: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -182,7 +187,7 @@ export const TabContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-dialog title="Tab Content Example" [background]="background" [dialogSize]="dialogSize" [disablePadding]="disablePadding">
|
||||
<bit-dialog title="Tab Content Example" [background]="background" [dialogSize]="dialogSize" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<span bitDialogContent>
|
||||
<bit-tab-group>
|
||||
<bit-tab label="First Tab">First Tab Content</bit-tab>
|
||||
@@ -200,6 +205,7 @@ export const TabContent: Story = {
|
||||
args: {
|
||||
dialogSize: "large",
|
||||
disablePadding: true,
|
||||
disableAnimations: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
@@ -219,7 +225,7 @@ export const WithCards: Story = {
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<ng-container bitDialogContent>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
@@ -283,5 +289,6 @@ export const WithCards: Story = {
|
||||
title: "Default",
|
||||
subtitle: "Subtitle",
|
||||
background: "alt",
|
||||
disableAnimations: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { ButtonModule } from "../../button";
|
||||
import { I18nMockService } from "../../utils/i18n-mock.service";
|
||||
import { DialogModule } from "../dialog.module";
|
||||
import { DialogService } from "../dialog.service";
|
||||
import { CenterPositionStrategy, DialogService } from "../dialog.service";
|
||||
|
||||
interface Animal {
|
||||
animal: string;
|
||||
@@ -33,28 +33,31 @@ class StoryDialogComponent {
|
||||
constructor(public dialogService: DialogService) {}
|
||||
|
||||
openSimpleDialog() {
|
||||
this.dialogService.open(SimpleDialogContent, {
|
||||
this.dialogService.open(SimpleDialogContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
|
||||
openNonDismissableWithPrimaryButtonDialog() {
|
||||
this.dialogService.open(NonDismissableWithPrimaryButtonContent, {
|
||||
this.dialogService.open(NonDismissableWithPrimaryButtonContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
disableClose: true,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
|
||||
openNonDismissableWithNoButtonsDialog() {
|
||||
this.dialogService.open(NonDismissableWithNoButtonsContent, {
|
||||
this.dialogService.open(NonDismissableWithNoButtonsContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
disableClose: true,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -80,7 +83,7 @@ class StoryDialogComponent {
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class SimpleDialogContent {
|
||||
class SimpleDialogContentComponent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
@@ -111,7 +114,7 @@ class SimpleDialogContent {
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class NonDismissableWithPrimaryButtonContent {
|
||||
class NonDismissableWithPrimaryButtonContentComponent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
@@ -137,7 +140,7 @@ class NonDismissableWithPrimaryButtonContent {
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class NonDismissableWithNoButtonsContent {
|
||||
class NonDismissableWithNoButtonsContentComponent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Directive, HostBinding, HostListener, input } from "@angular/core";
|
||||
import { Directive, computed, input } from "@angular/core";
|
||||
|
||||
import { DisclosureComponent } from "./disclosure.component";
|
||||
|
||||
/**
|
||||
* Directive that connects a trigger element (like a button) to a disclosure component.
|
||||
* Automatically handles click events to toggle the disclosure open/closed state and
|
||||
* manages ARIA attributes for accessibility.
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitDisclosureTriggerFor]",
|
||||
exportAs: "disclosureTriggerFor",
|
||||
host: {
|
||||
"[attr.aria-expanded]": "ariaExpanded()",
|
||||
"[attr.aria-controls]": "ariaControls()",
|
||||
"(click)": "toggle()",
|
||||
},
|
||||
})
|
||||
export class DisclosureTriggerForDirective {
|
||||
/**
|
||||
@@ -12,15 +22,11 @@ export class DisclosureTriggerForDirective {
|
||||
*/
|
||||
readonly disclosure = input.required<DisclosureComponent>({ alias: "bitDisclosureTriggerFor" });
|
||||
|
||||
@HostBinding("attr.aria-expanded") get ariaExpanded() {
|
||||
return this.disclosure().open;
|
||||
}
|
||||
protected readonly ariaExpanded = computed(() => this.disclosure().open());
|
||||
|
||||
@HostBinding("attr.aria-controls") get ariaControls() {
|
||||
return this.disclosure().id;
|
||||
}
|
||||
protected readonly ariaControls = computed(() => this.disclosure().id);
|
||||
|
||||
@HostListener("click") click() {
|
||||
this.disclosure().open = !this.disclosure().open;
|
||||
protected toggle() {
|
||||
this.disclosure().open.update((open) => !open);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,50 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Input,
|
||||
Output,
|
||||
booleanAttribute,
|
||||
} from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, model } from "@angular/core";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button.
|
||||
|
||||
* To compose a disclosure and trigger:
|
||||
|
||||
* 1. Create a trigger component (see "Supported Trigger Components" section below)
|
||||
* 2. Create a `bit-disclosure`
|
||||
* 3. Set a template reference on the `bit-disclosure`
|
||||
* 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference
|
||||
* 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <button
|
||||
* type="button"
|
||||
* bitIconButton="bwi-sliders"
|
||||
* [buttonType]="'muted'"
|
||||
* [bitDisclosureTriggerFor]="disclosureRef"
|
||||
* [label]="'Settings' | i18n"
|
||||
* ></button>
|
||||
* <bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
|
||||
* ```
|
||||
*
|
||||
* The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button.
|
||||
*
|
||||
* To compose a disclosure and trigger:
|
||||
*
|
||||
* 1. Create a trigger component (see "Supported Trigger Components" section below)
|
||||
* 2. Create a `bit-disclosure`
|
||||
* 3. Set a template reference on the `bit-disclosure`
|
||||
* 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference
|
||||
* 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <button
|
||||
* type="button"
|
||||
* bitIconButton="bwi-sliders"
|
||||
* buttonType="muted"
|
||||
* [bitDisclosureTriggerFor]="disclosureRef"
|
||||
* [label]="'Settings' | i18n"
|
||||
* ></button>
|
||||
* <bit-disclosure #disclosureRef [(open)]="isOpen">click button to hide this content</bit-disclosure>
|
||||
* ```
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-disclosure",
|
||||
template: `<ng-content></ng-content>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
"[class]": "classList()",
|
||||
"[id]": "id",
|
||||
},
|
||||
})
|
||||
export class DisclosureComponent {
|
||||
/** Emits the visibility of the disclosure content */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() openChange = new EventEmitter<boolean>();
|
||||
|
||||
private _open?: boolean;
|
||||
/**
|
||||
* Optionally init the disclosure in its opened state
|
||||
* Controls the visibility of the disclosure content.
|
||||
*/
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ transform: booleanAttribute }) set open(isOpen: boolean) {
|
||||
this._open = isOpen;
|
||||
this.openChange.emit(isOpen);
|
||||
}
|
||||
get open(): boolean {
|
||||
return !!this._open;
|
||||
}
|
||||
readonly open = model<boolean>(false);
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return this.open ? "" : "tw-hidden";
|
||||
}
|
||||
/**
|
||||
* Autogenerated id.
|
||||
*/
|
||||
readonly id = `bit-disclosure-${nextId++}`;
|
||||
|
||||
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
|
||||
protected readonly classList = computed(() => (this.open() ? "" : "tw-hidden"));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/c
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
<Canvas of={stories.DisclosureWithIconButton} />
|
||||
<Canvas of={stories.DisclosureOpen} />
|
||||
|
||||
## Supported Trigger Components
|
||||
|
||||
|
||||
@@ -36,13 +36,30 @@ export default {
|
||||
|
||||
type Story = StoryObj<DisclosureComponent>;
|
||||
|
||||
export const DisclosureWithIconButton: Story = {
|
||||
export const DisclosureOpen: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<button type="button" label="Settings" bitIconButton="bwi-sliders" [buttonType]="'muted'" [bitDisclosureTriggerFor]="disclosureRef">
|
||||
<button type="button" label="Settings" bitIconButton="bwi-sliders" buttonType="muted" [bitDisclosureTriggerFor]="disclosureRef">
|
||||
</button>
|
||||
<bit-disclosure #disclosureRef class="tw-text-main tw-block" open>click button to hide this content</bit-disclosure>
|
||||
<bit-disclosure #disclosureRef class="tw-text-main tw-block" [(open)]="open">click button to hide this content</bit-disclosure>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const DisclosureClosed: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<button type="button" label="Settings" bitIconButton="bwi-sliders" buttonType="muted" [bitDisclosureTriggerFor]="disclosureRef">
|
||||
</button>
|
||||
<bit-disclosure #disclosureRef class="tw-text-main tw-block" [(open)]="open">click button to hide this content</bit-disclosure>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { NgModule } from "@angular/core";
|
||||
|
||||
import { FormControlComponent } from "./form-control.component";
|
||||
import { BitHintComponent } from "./hint.component";
|
||||
import { BitLabel } from "./label.component";
|
||||
import { BitLabelComponent } from "./label.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [BitLabel, FormControlComponent, BitHintComponent],
|
||||
exports: [FormControlComponent, BitLabel, BitHintComponent],
|
||||
imports: [BitLabelComponent, FormControlComponent, BitHintComponent],
|
||||
exports: [FormControlComponent, BitLabelComponent, BitHintComponent],
|
||||
})
|
||||
export class FormControlModule {}
|
||||
|
||||
@@ -17,7 +17,7 @@ let nextId = 0;
|
||||
"[id]": "id()",
|
||||
},
|
||||
})
|
||||
export class BitLabel {
|
||||
export class BitLabelComponent {
|
||||
constructor(
|
||||
private elementRef: ElementRef<HTMLInputElement>,
|
||||
@Optional() private parentFormControl: FormControlComponent,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
},
|
||||
imports: [I18nPipe],
|
||||
})
|
||||
export class BitErrorSummary {
|
||||
export class BitErrorSummaryComponent {
|
||||
readonly formGroup = input<UntypedFormGroup>();
|
||||
|
||||
get errorCount(): number {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitHintComponent } from "../form-control/hint.component";
|
||||
import { BitLabel } from "../form-control/label.component";
|
||||
import { BitLabelComponent } from "../form-control/label.component";
|
||||
import { inputBorderClasses } from "../input/input.directive";
|
||||
|
||||
import { BitErrorComponent } from "./error.component";
|
||||
@@ -32,7 +32,7 @@ import { BitFormFieldControl } from "./form-field-control";
|
||||
export class BitFormFieldComponent implements AfterContentChecked {
|
||||
readonly input = contentChild.required(BitFormFieldControl);
|
||||
readonly hint = contentChild(BitHintComponent);
|
||||
readonly label = contentChild(BitLabel);
|
||||
readonly label = contentChild(BitLabelComponent);
|
||||
|
||||
readonly prefixContainer = viewChild<ElementRef<HTMLDivElement>>("prefixContainer");
|
||||
readonly suffixContainer = viewChild<ElementRef<HTMLDivElement>>("suffixContainer");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormControlModule } from "../form-control";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { MultiSelectModule } from "../multi-select/multi-select.module";
|
||||
|
||||
import { BitErrorSummary } from "./error-summary.component";
|
||||
import { BitErrorSummaryComponent } from "./error-summary.component";
|
||||
import { BitErrorComponent } from "./error.component";
|
||||
import { BitFormFieldComponent } from "./form-field.component";
|
||||
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
|
||||
@@ -18,7 +18,7 @@ import { BitSuffixDirective } from "./suffix.directive";
|
||||
MultiSelectModule,
|
||||
|
||||
BitErrorComponent,
|
||||
BitErrorSummary,
|
||||
BitErrorSummaryComponent,
|
||||
BitFormFieldComponent,
|
||||
BitPasswordInputToggleDirective,
|
||||
BitPrefixDirective,
|
||||
@@ -30,7 +30,7 @@ import { BitSuffixDirective } from "./suffix.directive";
|
||||
MultiSelectModule,
|
||||
|
||||
BitErrorComponent,
|
||||
BitErrorSummary,
|
||||
BitErrorSummaryComponent,
|
||||
BitFormFieldComponent,
|
||||
BitPasswordInputToggleDirective,
|
||||
BitPrefixDirective,
|
||||
|
||||
@@ -25,7 +25,7 @@ const commonStyles = [
|
||||
"tw-leading-none",
|
||||
"tw-px-0",
|
||||
"tw-py-0.5",
|
||||
"tw-font-medium",
|
||||
"tw-font-semibold",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./menu.module";
|
||||
export * from "./menu.component";
|
||||
export * from "./menu-trigger-for.directive";
|
||||
export * from "./menu-item.directive";
|
||||
export * from "./menu-item.component";
|
||||
export * from "./menu-divider.component";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
templateUrl: "menu-item.component.html",
|
||||
imports: [NgClass],
|
||||
})
|
||||
export class MenuItemDirective implements FocusableOption {
|
||||
export class MenuItemComponent implements FocusableOption {
|
||||
@HostBinding("class") classList = [
|
||||
"tw-block",
|
||||
"tw-w-full",
|
||||
@@ -7,7 +7,7 @@ import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
||||
import { MenuModule } from "./index";
|
||||
|
||||
describe("Menu", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let fixture: ComponentFixture<TestAppComponent>;
|
||||
const getMenuTriggerDirective = () => {
|
||||
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
|
||||
return buttonDebugElement.injector.get(MenuTriggerForDirective);
|
||||
@@ -18,12 +18,12 @@ describe("Menu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestApp],
|
||||
imports: [TestAppComponent],
|
||||
});
|
||||
|
||||
await TestBed.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture = TestBed.createComponent(TestAppComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -82,4 +82,4 @@ describe("Menu", () => {
|
||||
`,
|
||||
imports: [MenuModule],
|
||||
})
|
||||
class TestApp {}
|
||||
class TestAppComponent {}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
contentChildren,
|
||||
} from "@angular/core";
|
||||
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
import { MenuItemComponent } from "./menu-item.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -25,8 +25,8 @@ export class MenuComponent implements AfterContentInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
readonly menuItems = contentChildren(MenuItemDirective, { descendants: true });
|
||||
keyManager?: FocusKeyManager<MenuItemDirective>;
|
||||
readonly menuItems = contentChildren(MenuItemComponent, { descendants: true });
|
||||
keyManager?: FocusKeyManager<MenuItemComponent>;
|
||||
|
||||
readonly ariaRole = input<"menu" | "dialog">("menu");
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { MenuDividerComponent } from "./menu-divider.component";
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
import { MenuItemComponent } from "./menu-item.component";
|
||||
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
||||
import { MenuComponent } from "./menu.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||
exports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||
imports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent],
|
||||
exports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
|
||||
@@ -2,32 +2,30 @@ import { Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs";
|
||||
|
||||
type CollapsePreference = "open" | "closed" | null;
|
||||
import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
|
||||
|
||||
const SMALL_SCREEN_BREAKPOINT_PX = 768;
|
||||
type CollapsePreference = "open" | "closed" | null;
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SideNavService {
|
||||
private _open$ = new BehaviorSubject<boolean>(
|
||||
!window.matchMedia(`(max-width: ${SMALL_SCREEN_BREAKPOINT_PX}px)`).matches,
|
||||
);
|
||||
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
|
||||
open$ = this._open$.asObservable();
|
||||
|
||||
private isSmallScreen$ = media(`(max-width: ${SMALL_SCREEN_BREAKPOINT_PX}px)`);
|
||||
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
|
||||
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
|
||||
|
||||
isOverlay$ = combineLatest([this.open$, this.isSmallScreen$]).pipe(
|
||||
map(([open, isSmallScreen]) => open && isSmallScreen),
|
||||
isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe(
|
||||
map(([open, isLargeScreen]) => open && !isLargeScreen),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
combineLatest([this.isSmallScreen$, this.userCollapsePreference$])
|
||||
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(([isSmallScreen, userCollapsePreference]) => {
|
||||
if (isSmallScreen) {
|
||||
.subscribe(([isLargeScreen, userCollapsePreference]) => {
|
||||
if (!isLargeScreen) {
|
||||
this.setClose();
|
||||
} else if (userCollapsePreference !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
|
||||
@@ -11,19 +11,19 @@ import { RadioButtonComponent } from "./radio-button.component";
|
||||
import { RadioButtonModule } from "./radio-button.module";
|
||||
|
||||
describe("RadioGroupComponent", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let testAppComponent: TestApp;
|
||||
let fixture: ComponentFixture<TestAppComponent>;
|
||||
let testAppComponent: TestAppComponent;
|
||||
let buttonElements: RadioButtonComponent[];
|
||||
let radioButtons: HTMLInputElement[];
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestApp],
|
||||
imports: [TestAppComponent],
|
||||
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
|
||||
});
|
||||
|
||||
await TestBed.compileComponents();
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture = TestBed.createComponent(TestAppComponent);
|
||||
fixture.detectChanges();
|
||||
testAppComponent = fixture.debugElement.componentInstance;
|
||||
buttonElements = fixture.debugElement
|
||||
@@ -76,6 +76,6 @@ describe("RadioGroupComponent", () => {
|
||||
`,
|
||||
imports: [FormsModule, RadioButtonModule],
|
||||
})
|
||||
class TestApp {
|
||||
class TestAppComponent {
|
||||
selected?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitLabel } from "../form-control/label.component";
|
||||
import { BitLabelComponent } from "../form-control/label.component";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@@ -32,7 +32,7 @@ export class RadioGroupComponent implements ControlValueAccessor {
|
||||
readonly id = input(`bit-radio-group-${nextId++}`);
|
||||
@HostBinding("class") classList = ["tw-block", "tw-mb-4"];
|
||||
|
||||
protected readonly label = contentChild(BitLabel);
|
||||
protected readonly label = contentChild(BitLabelComponent);
|
||||
|
||||
constructor(@Optional() @Self() private ngControl?: NgControl) {
|
||||
if (ngControl != null) {
|
||||
|
||||
@@ -134,7 +134,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class KitchenSinkForm {
|
||||
export class KitchenSinkFormComponent {
|
||||
constructor(
|
||||
public dialogService: DialogService,
|
||||
public formBuilder: FormBuilder,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Component, signal } from "@angular/core";
|
||||
import { DialogService } from "../../../dialog";
|
||||
import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
|
||||
import { KitchenSinkForm } from "./kitchen-sink-form.component";
|
||||
import { KitchenSinkTable } from "./kitchen-sink-table.component";
|
||||
import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
|
||||
import { KitchenSinkFormComponent } from "./kitchen-sink-form.component";
|
||||
import { KitchenSinkTableComponent } from "./kitchen-sink-table.component";
|
||||
import { KitchenSinkToggleListComponent } from "./kitchen-sink-toggle-list.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -83,7 +83,7 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
|
||||
</bit-dialog>
|
||||
`,
|
||||
})
|
||||
class KitchenSinkDialog {
|
||||
class KitchenSinkDialogComponent {
|
||||
constructor(public dialogRef: DialogRef) {}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,12 @@ class KitchenSinkDialog {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-tab-main",
|
||||
imports: [KitchenSinkSharedModule, KitchenSinkTable, KitchenSinkToggleList, KitchenSinkForm],
|
||||
imports: [
|
||||
KitchenSinkSharedModule,
|
||||
KitchenSinkTableComponent,
|
||||
KitchenSinkToggleListComponent,
|
||||
KitchenSinkFormComponent,
|
||||
],
|
||||
template: `
|
||||
<bit-banner bannerType="info"> Kitchen Sink test zone </bit-banner>
|
||||
|
||||
@@ -182,11 +187,11 @@ export class KitchenSinkMainComponent {
|
||||
protected readonly drawerOpen = signal(false);
|
||||
|
||||
openDialog() {
|
||||
this.dialogService.open(KitchenSinkDialog);
|
||||
this.dialogService.open(KitchenSinkDialogComponent);
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.dialogService.openDrawer(KitchenSinkDialog);
|
||||
this.dialogService.openDrawer(KitchenSinkDialogComponent);
|
||||
}
|
||||
|
||||
navItems = [
|
||||
|
||||
@@ -57,4 +57,4 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
</bit-table>
|
||||
`,
|
||||
})
|
||||
export class KitchenSinkTable {}
|
||||
export class KitchenSinkTableComponent {}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class KitchenSinkToggleList {
|
||||
export class KitchenSinkToggleListComponent {
|
||||
selectedToggle: "all" | "large" | "small" = "all";
|
||||
|
||||
companyList = [
|
||||
|
||||
@@ -19,10 +19,10 @@ import { I18nMockService } from "../../utils/i18n-mock.service";
|
||||
import { positionFixedWrapperDecorator } from "../storybook-decorators";
|
||||
|
||||
import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component";
|
||||
import { KitchenSinkForm } from "./components/kitchen-sink-form.component";
|
||||
import { KitchenSinkFormComponent } from "./components/kitchen-sink-form.component";
|
||||
import { KitchenSinkMainComponent } from "./components/kitchen-sink-main.component";
|
||||
import { KitchenSinkTable } from "./components/kitchen-sink-table.component";
|
||||
import { KitchenSinkToggleList } from "./components/kitchen-sink-toggle-list.component";
|
||||
import { KitchenSinkTableComponent } from "./components/kitchen-sink-table.component";
|
||||
import { KitchenSinkToggleListComponent } from "./components/kitchen-sink-toggle-list.component";
|
||||
import { KitchenSinkSharedModule } from "./kitchen-sink-shared.module";
|
||||
|
||||
export default {
|
||||
@@ -33,10 +33,10 @@ export default {
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
KitchenSinkSharedModule,
|
||||
KitchenSinkForm,
|
||||
KitchenSinkFormComponent,
|
||||
KitchenSinkMainComponent,
|
||||
KitchenSinkTable,
|
||||
KitchenSinkToggleList,
|
||||
KitchenSinkTableComponent,
|
||||
KitchenSinkToggleListComponent,
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { BitLabel } from "../form-control/label.component";
|
||||
import { BitLabelComponent } from "../form-control/label.component";
|
||||
|
||||
import { SwitchComponent } from "./switch.component";
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("SwitchComponent", () => {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "test-host",
|
||||
imports: [FormsModule, BitLabel, ReactiveFormsModule, SwitchComponent],
|
||||
imports: [FormsModule, BitLabelComponent, ReactiveFormsModule, SwitchComponent],
|
||||
template: `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-switch formControlName="switch">
|
||||
@@ -77,7 +77,7 @@ describe("SwitchComponent", () => {
|
||||
selector: "test-selected-host",
|
||||
template: `<bit-switch [selected]="checked"><bit-label>Element</bit-label></bit-switch>`,
|
||||
standalone: true,
|
||||
imports: [SwitchComponent, BitLabel],
|
||||
imports: [SwitchComponent, BitLabelComponent],
|
||||
})
|
||||
class TestSelectedHostComponent {
|
||||
checked = false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
import { AriaDisableDirective } from "../a11y";
|
||||
import { FormControlModule } from "../form-control/form-control.module";
|
||||
import { BitHintComponent } from "../form-control/hint.component";
|
||||
import { BitLabel } from "../form-control/label.component";
|
||||
import { BitLabelComponent } from "../form-control/label.component";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@@ -43,7 +43,7 @@ let nextId = 0;
|
||||
})
|
||||
export class SwitchComponent implements ControlValueAccessor, AfterViewInit {
|
||||
private el = inject(ElementRef<HTMLButtonElement>);
|
||||
private readonly label = contentChild.required(BitLabel);
|
||||
private readonly label = contentChild.required(BitLabelComponent);
|
||||
|
||||
/**
|
||||
* Model signal for selected state binding when used outside of a form
|
||||
|
||||
@@ -6,18 +6,18 @@ import { ToggleGroupModule } from "./toggle-group.module";
|
||||
import { ToggleComponent } from "./toggle.component";
|
||||
|
||||
describe("Button", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let testAppComponent: TestApp;
|
||||
let fixture: ComponentFixture<TestAppComponent>;
|
||||
let testAppComponent: TestAppComponent;
|
||||
let buttonElements: ToggleComponent<unknown>[];
|
||||
let radioButtons: HTMLInputElement[];
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestApp],
|
||||
imports: [TestAppComponent],
|
||||
});
|
||||
|
||||
await TestBed.compileComponents();
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture = TestBed.createComponent(TestAppComponent);
|
||||
testAppComponent = fixture.debugElement.componentInstance;
|
||||
buttonElements = fixture.debugElement
|
||||
.queryAll(By.css("bit-toggle"))
|
||||
@@ -66,6 +66,6 @@ describe("Button", () => {
|
||||
`,
|
||||
imports: [ToggleGroupModule],
|
||||
})
|
||||
class TestApp {
|
||||
class TestAppComponent {
|
||||
selected?: string;
|
||||
}
|
||||
|
||||
@@ -27,42 +27,42 @@ const typographyProps: TypographyData[] = [
|
||||
{
|
||||
id: "h1",
|
||||
typography: "h1",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 30,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
typography: "h2",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 24,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
typography: "h3",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 20,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h4",
|
||||
typography: "h4",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 18,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h5",
|
||||
typography: "h5",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 16,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
{
|
||||
id: "h6",
|
||||
typography: "h6",
|
||||
weight: "Regular",
|
||||
weight: "Medium",
|
||||
size: 14,
|
||||
lineHeight: "150%",
|
||||
},
|
||||
|
||||
27
libs/components/src/utils/responsive-utils.ts
Normal file
27
libs/components/src/utils/responsive-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Breakpoint definitions in pixels matching Tailwind CSS default breakpoints.
|
||||
* These values must stay in sync with tailwind.config.base.js theme.extend configuration.
|
||||
*
|
||||
* @see {@link https://tailwindcss.com/docs/responsive-design} for tailwind default breakpoints
|
||||
* @see {@link /libs/components/src/stories/responsive-design.mdx} for design system usage
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current viewport is at or larger than the specified breakpoint.
|
||||
* @param size The breakpoint to check.
|
||||
* @returns True if the viewport is at or larger than the breakpoint, false otherwise.
|
||||
*/
|
||||
export const isAtOrLargerThanBreakpoint = (size: keyof typeof BREAKPOINTS): boolean => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) {
|
||||
return false;
|
||||
}
|
||||
const query = `(min-width: ${BREAKPOINTS[size]}px)`;
|
||||
return window.matchMedia(query).matches;
|
||||
};
|
||||
@@ -167,6 +167,20 @@ module.exports = {
|
||||
container: {
|
||||
"@5xl": "1100px",
|
||||
},
|
||||
keyframes: {
|
||||
slideUp: {
|
||||
"0%": { opacity: "0", transform: "translateY(50px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
slideDown: {
|
||||
"0%": { opacity: "0", transform: "translateY(-50px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"slide-up": "slideUp 0.3s ease-out",
|
||||
"slide-down": "slideDown 0.3s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<bit-callout type="info" *ngIf="importBlockedByPolicy">
|
||||
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
||||
</bit-callout>
|
||||
<bit-callout
|
||||
[title]="'restrictCardTypeImport' | i18n"
|
||||
type="info"
|
||||
|
||||
@@ -127,6 +127,7 @@ export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||
export const IPC_MEMORY = new StateDefinition("interProcessCommunication", "memory");
|
||||
export const POPUP_VIEW_MEMORY = new StateDefinition("popupView", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogService,
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type AdvancedUriOptionDialogParams = {
|
||||
@@ -55,6 +56,7 @@ export class AdvancedUriOptionDialogComponent {
|
||||
return dialogService.open<boolean>(AdvancedUriOptionDialogComponent, {
|
||||
data: params,
|
||||
disableClose: true,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,13 +154,13 @@ describe("CipherFormComponent", () => {
|
||||
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
||||
});
|
||||
|
||||
it("clears archiveDate on updatedCipherView", async () => {
|
||||
it("does not clear archiveDate on updatedCipherView", async () => {
|
||||
cipherView.archivedDate = new Date();
|
||||
decryptCipher.mockResolvedValue(cipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBe(cipherView.archivedDate);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -281,7 +281,6 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
|
||||
if (this.config.mode === "clone") {
|
||||
this.updatedCipherView.id = null;
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
|
||||
if (this.updatedCipherView.login) {
|
||||
this.updatedCipherView.login.fido2Credentials = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components";
|
||||
import { BitIconButtonComponent, MenuItemComponent } from "@bitwarden/components";
|
||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
import { CopyCipherFieldDirective } from "./copy-cipher-field.directive";
|
||||
@@ -83,7 +83,7 @@ describe("CopyCipherFieldDirective", () => {
|
||||
});
|
||||
|
||||
it("updates menuItemDirective disabled state", async () => {
|
||||
const menuItemDirective = {
|
||||
const menuItemComponent = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
@@ -91,14 +91,14 @@ describe("CopyCipherFieldDirective", () => {
|
||||
copyFieldService as unknown as CopyCipherFieldService,
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
menuItemDirective as unknown as MenuItemDirective,
|
||||
menuItemComponent as unknown as MenuItemComponent,
|
||||
);
|
||||
|
||||
copyCipherFieldDirective.action = "totp";
|
||||
|
||||
await copyCipherFieldDirective.ngOnChanges();
|
||||
|
||||
expect(menuItemDirective.disabled).toBe(true);
|
||||
expect(menuItemComponent.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
|
||||
import { MenuItemComponent, BitIconButtonComponent } from "@bitwarden/components";
|
||||
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
/**
|
||||
@@ -47,7 +47,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
private copyCipherFieldService: CopyCipherFieldService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
@Optional() private menuItemDirective?: MenuItemDirective,
|
||||
@Optional() private menuItemComponent?: MenuItemComponent,
|
||||
@Optional() private iconButtonComponent?: BitIconButtonComponent,
|
||||
) {}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
*/
|
||||
@HostBinding("class.tw-hidden")
|
||||
private get hidden() {
|
||||
return this.disabled && this.menuItemDirective;
|
||||
return this.disabled && this.menuItemComponent;
|
||||
}
|
||||
|
||||
@HostListener("click")
|
||||
@@ -87,8 +87,8 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
}
|
||||
|
||||
// If the directive is used on a menu item, update the menu item to prevent keyboard navigation
|
||||
if (this.menuItemDirective) {
|
||||
this.menuItemDirective.disabled = this.disabled ?? false;
|
||||
if (this.menuItemComponent) {
|
||||
this.menuItemComponent.disabled = this.disabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DialogModule,
|
||||
DialogService,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type DecryptionFailureDialogParams = {
|
||||
@@ -56,6 +57,9 @@ export class DecryptionFailureDialogComponent {
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, params: DecryptionFailureDialogParams) {
|
||||
return dialogService.open(DecryptionFailureDialogComponent, { data: params });
|
||||
return dialogService.open(DecryptionFailureDialogComponent, {
|
||||
data: params,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user