1
0
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:
rr-bw
2025-11-20 15:42:30 -08:00
464 changed files with 17166 additions and 5543 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export * from "./ipc-message";
export * from "./ipc.service";
export * from "./ipc-session-repository";

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

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/c
<Title />
<Description />
<Canvas of={stories.DisclosureWithIconButton} />
<Canvas of={stories.DisclosureOpen} />
## Supported Trigger Components

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ let nextId = 0;
"[id]": "id()",
},
})
export class BitLabel {
export class BitLabelComponent {
constructor(
private elementRef: ElementRef<HTMLInputElement>,
@Optional() private parentFormControl: FormControlComponent,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,4 +57,4 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
</bit-table>
`,
})
export class KitchenSinkTable {}
export class KitchenSinkTableComponent {}

View File

@@ -28,7 +28,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
}
`,
})
export class KitchenSinkToggleList {
export class KitchenSinkToggleListComponent {
selectedToggle: "all" | "large" | "small" = "all";
companyList = [

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,6 +1,3 @@
<bit-callout type="info" *ngIf="importBlockedByPolicy">
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
</bit-callout>
<bit-callout
[title]="'restrictCardTypeImport' | i18n"
type="info"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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