1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-02 11:31:44 +00:00

Merge branch 'main' into km/pm-14445-crypto

This commit is contained in:
Maciej Zieniuk
2025-02-28 00:00:04 +00:00
183 changed files with 8751 additions and 3921 deletions

View File

@@ -1,7 +1,7 @@
import { webcrypto } from "crypto";
import { addCustomMatchers } from "@bitwarden/common/spec";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
addCustomMatchers();

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, from, map, mergeMap, Observable, switchMap } from "rxjs";
import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -85,6 +85,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
};
return this.accountService.activeAccount$.pipe(
take(1),
getUserId,
switchMap((userId) =>
this.folderService

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -32,7 +32,8 @@
</button>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span>
<span>{{ "needAnotherOptionV1" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
@@ -46,7 +47,8 @@
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span>
<span>{{ "troubleLoggingIn" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>

View File

@@ -629,12 +629,7 @@ export class LoginComponent implements OnInit, OnDestroy {
* Handle the SSO button click.
*/
async handleSsoClick() {
// Make sure the email is not empty, for type safety
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
// Make sure the email is valid
const isEmailValid = await this.validateEmail();
@@ -642,6 +637,12 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
// Make sure the email is not empty, for type safety
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
// Save the email configuration for the login component
await this.saveEmailSettings();

View File

@@ -25,6 +25,7 @@
<div class="tw-flex tw-mt-4">
<button
bitButton
bitFormButton
buttonType="primary"
type="submit"
[block]="true"

View File

@@ -155,13 +155,6 @@ export class SsoComponent implements OnInit {
return;
}
// Detect if we are on the first portion of the SSO flow
// and have been sent here from another client with the info in query params
if (this.hasParametersFromOtherClientRedirect(qParams)) {
this.initializeFromRedirectFromOtherClient(qParams);
return;
}
// Detect if we have landed here but only have an SSO identifier in the URL.
// This is used by integrations that want to "short-circuit" the login to send users
// directly to their IdP to simulate IdP-initiated SSO, so we submit automatically.
@@ -172,8 +165,15 @@ export class SsoComponent implements OnInit {
return;
}
// If we're routed here with no additional parameters, we'll try to determine the
// identifier using claimed domain or local state saved from their last attempt.
// Detect if we are on the first portion of the SSO flow
// and have been sent here from another client with the info in query params.
// If so, we want to initialize the SSO flow with those values.
if (this.hasParametersFromOtherClientRedirect(qParams)) {
this.initializeFromRedirectFromOtherClient(qParams);
}
// Try to determine the identifier using claimed domain or local state
// persisted from the user's last login attempt.
await this.initializeIdentifierFromEmailOrStorage();
}
@@ -445,13 +445,6 @@ export class SsoComponent implements OnInit {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
return await this.handleForcePasswordReset(orgSsoIdentifier);
}
// must come after 2fa check since user decryption options aren't available if 2fa is required
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,

View File

@@ -16,7 +16,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
const path = `/auth-requests/${requestId}`;
const response = await this.apiService.send("GET", path, null, true, true);
return response;
return new AuthRequestResponse(response);
} catch (e: unknown) {
this.logService.error(e);
throw e;
@@ -28,7 +28,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
const path = `/auth-requests/${requestId}/response?code=${accessCode}`;
const response = await this.apiService.send("GET", path, null, false, true);
return response;
return new AuthRequestResponse(response);
} catch (e: unknown) {
this.logService.error(e);
throw e;
@@ -45,7 +45,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
true,
);
return response;
return new AuthRequestResponse(response);
} catch (e: unknown) {
this.logService.error(e);
throw e;
@@ -54,9 +54,22 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
try {
const response = await this.apiService.send("POST", "/auth-requests/", request, false, true);
// Submit the current device identifier in the header as well as in the POST body.
// The value in the header will be used to build the request context and ensure that the resulting
// notifications have the current device as a source.
const response = await this.apiService.send(
"POST",
"/auth-requests/",
request,
false,
true,
null,
(headers) => {
headers.set("Device-Identifier", request.deviceIdentifier);
},
);
return response;
return new AuthRequestResponse(response);
} catch (e: unknown) {
this.logService.error(e);
throw e;

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -142,7 +142,7 @@ export abstract class ApiService {
body: any,
authed: boolean,
hasResponse: boolean,
apiUrl?: string,
apiUrl?: string | null,
alterHeaders?: (headers: Headers) => void,
) => Promise<any>;

View File

@@ -45,9 +45,9 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
NewDeviceVerification = "new-device-verification",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
RecoveryCodeLogin = "pm-17128-recovery-code-login",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -103,9 +103,9 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
[FeatureFlag.NewDeviceVerification]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.RecoveryCodeLogin]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -128,7 +128,7 @@ export abstract class EnvironmentService {
/**
* Get the environment from state. Useful if you need to get the environment for another user.
*/
abstract getEnvironment$(userId?: string): Observable<Environment | undefined>;
abstract getEnvironment$(userId: UserId): Observable<Environment | undefined>;
/**
* @deprecated Use {@link getEnvironment$} instead.

View File

@@ -1,3 +1,52 @@
export abstract class SdkLoadService {
abstract load(): Promise<void>;
import { init_sdk } from "@bitwarden/sdk-internal";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
import type { SdkService } from "./sdk.service";
export class SdkLoadFailedError extends Error {
constructor(error: unknown) {
super(`SDK loading failed: ${error}`);
}
}
export abstract class SdkLoadService {
private static markAsReady: () => void;
private static markAsFailed: (error: unknown) => void;
/**
* This promise is resolved when the SDK is ready to be used. Use it when your code might run early and/or is not able to use DI.
* Beware that WASM always requires a load step which makes it tricky to use functions and classes directly, it is therefore recommended
* to use the SDK through the {@link SdkService}. Only use this promise in advanced scenarios!
*
* @example
* ```typescript
* import { pureFunction } from "@bitwarden/sdk-internal";
*
* async function myFunction() {
* await SdkLoadService.Ready;
* pureFunction();
* }
* ```
*/
static readonly Ready = new Promise<void>((resolve, reject) => {
SdkLoadService.markAsReady = resolve;
SdkLoadService.markAsFailed = (error: unknown) => reject(new SdkLoadFailedError(error));
});
/**
* Load WASM and initalize SDK-JS integrations such as logging.
* This method should be called once at the start of the application.
* Raw functions and classes from the SDK can be used after this method resolves.
*/
async loadAndInit(): Promise<void> {
try {
await this.load();
init_sdk();
SdkLoadService.markAsReady();
} catch (error) {
SdkLoadService.markAsFailed(error);
}
}
protected abstract load(): Promise<void>;
}

View File

@@ -1,11 +1,3 @@
// Temporary workaround for Symbol.dispose
// remove when https://github.com/jestjs/jest/issues/14874 is resolved and *released*
const disposeSymbol: unique symbol = Symbol("Symbol.dispose");
const asyncDisposeSymbol: unique symbol = Symbol("Symbol.asyncDispose");
(Symbol as any).asyncDispose ??= asyncDisposeSymbol as unknown as SymbolConstructor["asyncDispose"];
(Symbol as any).dispose ??= disposeSymbol as unknown as SymbolConstructor["dispose"];
// Import needs to be after the workaround
import { Rc } from "./rc";
export class FreeableTestValue {

View File

@@ -304,85 +304,21 @@ describe("EnvironmentService", () => {
});
});
describe("getEnvironment", () => {
describe("getEnvironment$", () => {
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from user data if there is an active user", async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls());
])("gets it from the passed in userId: %s", async ({ region, expectedHost }) => {
setUserData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls(), alternateTestUser);
await switchUser(testUser);
const env = await firstValueFrom(sut.getEnvironment$());
expect(env.getHostname()).toBe(expectedHost);
const env = await firstValueFrom(sut.getEnvironment$(alternateTestUser));
expect(env?.getHostname()).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from global data if there is no active user", async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
const env = await firstValueFrom(sut.getEnvironment$());
expect(env.getHostname()).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from global state if there is no active user even if a user id is passed in.",
async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
const env = await firstValueFrom(sut.getEnvironment$(testUser));
expect(env.getHostname()).toBe(expectedHost);
},
);
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from the passed in userId if there is any active user: %s",
async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls(), alternateTestUser);
await switchUser(testUser);
const env = await firstValueFrom(sut.getEnvironment$(alternateTestUser));
expect(env.getHostname()).toBe(expectedHost);
},
);
it("gets it from base url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
const env = await firstValueFrom(sut.getEnvironment$());
expect(env.getHostname()).toBe("base.example.com");
});
it("gets it from webVault url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.webVault = "https://vault.example.com";
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
const env = await firstValueFrom(sut.getEnvironment$());
expect(env.getHostname()).toBe("vault.example.com");
});
it("gets it from saved self host config from passed in user when there is an active user", async () => {
setGlobalData(Region.US, new EnvironmentUrls());
it("gets env from saved self host config from passed in user when there is a different active user", async () => {
setUserData(Region.EU, new EnvironmentUrls());
const selfHostUserUrls = new EnvironmentUrls();
@@ -392,7 +328,31 @@ describe("EnvironmentService", () => {
await switchUser(testUser);
const env = await firstValueFrom(sut.getEnvironment$(alternateTestUser));
expect(env.getHostname()).toBe("base.example.com");
expect(env?.getHostname()).toBe("base.example.com");
});
});
describe("getEnvironment (deprecated)", () => {
it("gets self hosted env from active user when no user passed in", async () => {
const selfHostUserUrls = new EnvironmentUrls();
selfHostUserUrls.base = "https://base.example.com";
setUserData(Region.SelfHosted, selfHostUserUrls);
await switchUser(testUser);
const env = await sut.getEnvironment();
expect(env?.getHostname()).toBe("base.example.com");
});
it("gets self hosted env from passed in user", async () => {
const selfHostUserUrls = new EnvironmentUrls();
selfHostUserUrls.base = "https://base.example.com";
setUserData(Region.SelfHosted, selfHostUserUrls);
await switchUser(testUser);
const env = await sut.getEnvironment(testUser);
expect(env?.getHostname()).toBe("base.example.com");
});
});

View File

@@ -271,19 +271,8 @@ export class DefaultEnvironmentService implements EnvironmentService {
}
}
getEnvironment$(userId?: UserId): Observable<Environment | undefined> {
if (userId == null) {
return this.environment$;
}
return this.activeAccountId$.pipe(
switchMap((activeUserId) => {
// Previous rules dictated that we only get from user scoped state if there is an active user.
if (activeUserId == null) {
return this.globalState.state$;
}
return this.stateProvider.getUser(userId ?? activeUserId, USER_ENVIRONMENT_KEY).state$;
}),
getEnvironment$(userId: UserId): Observable<Environment | undefined> {
return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe(
map((state) => {
return this.buildEnvironment(state?.region, state?.urls);
}),
@@ -294,7 +283,10 @@ export class DefaultEnvironmentService implements EnvironmentService {
* @deprecated Use getEnvironment$ instead.
*/
async getEnvironment(userId?: UserId): Promise<Environment | undefined> {
return firstValueFrom(this.getEnvironment$(userId));
// Add backwards compatibility support for null userId
const definedUserId = userId ?? (await firstValueFrom(this.activeAccountId$));
return firstValueFrom(this.getEnvironment$(definedUserId));
}
async seedUserEnvironment(userId: UserId) {

View File

@@ -8,7 +8,7 @@ import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
*
* **Warning**: This requires WASM support and will fail if the environment does not support it.
*/
export class DefaultSdkLoadService implements SdkLoadService {
export class DefaultSdkLoadService extends SdkLoadService {
async load(): Promise<void> {
(sdk as any).init(bitwardenModule);
}

View File

@@ -11,6 +11,7 @@ import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { Rc } from "../../misc/reference-counting/rc";
import { EncryptedString } from "../../models/domain/enc-string";
@@ -18,6 +19,13 @@ import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { DefaultSdkService } from "./default-sdk.service";
class TestSdkLoadService extends SdkLoadService {
protected override load(): Promise<void> {
// Simulate successfull WASM load
return Promise.resolve();
}
}
describe("DefaultSdkService", () => {
describe("userClient$", () => {
let sdkClientFactory!: MockProxy<SdkClientFactory>;
@@ -28,7 +36,9 @@ describe("DefaultSdkService", () => {
let keyService!: MockProxy<KeyService>;
let service!: DefaultSdkService;
beforeEach(() => {
beforeEach(async () => {
await new TestSdkLoadService().loadAndInit();
sdkClientFactory = mock<SdkClientFactory>();
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();

View File

@@ -18,7 +18,6 @@ import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key
import {
BitwardenClient,
ClientSettings,
LogLevel,
DeviceType as SdkDeviceType,
} from "@bitwarden/sdk-internal";
@@ -30,6 +29,7 @@ import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { SdkService, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { compareValues } from "../../misc/compare-values";
import { Rc } from "../../misc/reference-counting/rc";
@@ -47,8 +47,9 @@ export class DefaultSdkService implements SdkService {
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
await SdkLoadService.Ready;
const settings = this.toSettings(env);
return await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
return await this.sdkClientFactory.createSdkClient(settings);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -135,6 +136,7 @@ export class DefaultSdkService implements SdkService {
privateKey$,
userKey$,
orgKeys$,
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
]).pipe(
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => {
@@ -146,7 +148,7 @@ export class DefaultSdkService implements SdkService {
}
const settings = this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
const client = await this.sdkClientFactory.createSdkClient(settings);
await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys);

View File

@@ -2,6 +2,6 @@ import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
export class NoopSdkLoadService extends SdkLoadService {
async load() {
return;
throw new Error("SDK not available in this environment");
}
}

View File

@@ -199,3 +199,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
);
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");

View File

@@ -1863,7 +1863,7 @@ export class ApiService implements ApiServiceAbstraction {
body: any,
authed: boolean,
hasResponse: boolean,
apiUrl?: string,
apiUrl?: string | null,
alterHeaders?: (headers: Headers) => void,
): Promise<any> {
const env = await firstValueFrom(this.environmentService.environment$);

View File

@@ -1,3 +1,5 @@
import "core-js/proposals/explicit-resource-management";
import { webcrypto } from "crypto";
import { addCustomMatchers } from "./spec";

View File

@@ -21,30 +21,35 @@ export class BitActionDirective implements OnDestroy {
private destroy$ = new Subject<void>();
private _loading$ = new BehaviorSubject<boolean>(false);
disabled = false;
@Input("bitAction") handler: FunctionReturningAwaitable;
/**
* Observable of loading behavior subject
*
* Used in `form-button.directive.ts`
*/
readonly loading$ = this._loading$.asObservable();
constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService,
) {}
get loading() {
return this._loading$.value;
}
set loading(value: boolean) {
this._loading$.next(value);
this.buttonComponent.loading = value;
this.buttonComponent.loading.set(value);
}
disabled = false;
@Input("bitAction") handler: FunctionReturningAwaitable;
constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService,
) {}
@HostListener("click")
protected async onClick() {
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled) {
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled()) {
return;
}

View File

@@ -41,15 +41,15 @@ export class BitFormButtonDirective implements OnDestroy {
if (submitDirective && buttonComponent) {
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
if (this.type === "submit") {
buttonComponent.loading = loading;
buttonComponent.loading.set(loading);
} else {
buttonComponent.disabled = this.disabled || loading;
buttonComponent.disabled.set(this.disabled || loading);
}
});
submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
if (this.disabled !== false) {
buttonComponent.disabled = this.disabled || disabled;
buttonComponent.disabled.set(this.disabled || disabled);
}
});
}

View File

@@ -1,6 +1,7 @@
import { Meta } from "@storybook/addon-docs";
import { Meta, Story } from "@storybook/addon-docs";
import * as stories from "./standalone.stories.ts";
<Meta title="Component Library/Async Actions/Standalone/Documentation" />
<Meta of={stories} />
# Standalone Async Actions
@@ -8,9 +9,13 @@ These directives should be used when building a standalone button that triggers
in the background, eg. Refresh buttons. For non-submit buttons that are associated with forms see
[Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page).
If the long running background task resolves quickly (e.g. less than 75 ms), the loading spinner
will not display on the button. This prevents an undesirable "flicker" of the loading spinner when
it is not necessary for the user to see it.
## Usage
Adding async actions to standalone buttons requires the following 2 steps
Adding async actions to standalone buttons requires the following 2 steps:
### 1. Add a handler to your `Component`
@@ -60,3 +65,21 @@ from how click handlers are usually defined with the output syntax `(click)="han
<button bitIconButton="bwi-trash" [bitAction]="handler"></button>`;
```
## Stories
### Promise resolves -- loading spinner is displayed
<Story of={stories.UsingPromise} />
### Promise resolves -- quickly without loading spinner
<Story of={stories.ActionResolvesQuickly} />
### Promise rejects
<Story of={stories.RejectedPromise} />
### Observable
<Story of={stories.UsingObservable} />

View File

@@ -11,9 +11,9 @@ import { IconButtonModule } from "../icon-button";
import { BitActionDirective } from "./bit-action.directive";
const template = `
const template = /*html*/ `
<button bitButton buttonType="primary" [bitAction]="action" class="tw-mr-2">
Perform action
Perform action {{ statusEmoji }}
</button>
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;
@@ -22,9 +22,30 @@ const template = `
selector: "app-promise-example",
})
class PromiseExampleComponent {
statusEmoji = "🟡";
action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
setTimeout(() => {
resolve();
this.statusEmoji = "🟢";
}, 5000);
});
};
}
@Component({
template,
selector: "app-action-resolves-quickly",
})
class ActionResolvesQuicklyComponent {
statusEmoji = "🟡";
action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(() => {
resolve();
this.statusEmoji = "🟢";
}, 50);
});
};
}
@@ -59,6 +80,7 @@ export default {
PromiseExampleComponent,
ObservableExampleComponent,
RejectedPromiseExampleComponent,
ActionResolvesQuicklyComponent,
],
imports: [ButtonModule, IconButtonModule, BitActionDirective],
providers: [
@@ -100,3 +122,10 @@ export const RejectedPromise: ObservableStory = {
template: `<app-rejected-promise-example></app-rejected-promise-example>`,
}),
};
export const ActionResolvesQuickly: PromiseStory = {
render: (args) => ({
props: args,
template: `<app-action-resolves-quickly></app-action-resolves-quickly>`,
}),
};

View File

@@ -1,10 +1,10 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<ng-content></ng-content>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</span>

View File

@@ -2,7 +2,9 @@
// @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common";
import { Input, HostBinding, Component } from "@angular/core";
import { Input, HostBinding, Component, model, computed } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounce, interval } from "rxjs";
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
@@ -49,6 +51,9 @@ const buttonStyles: Record<ButtonType, string[]> = {
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
standalone: true,
imports: [NgClass],
host: {
"[attr.disabled]": "disabledAttr()",
},
})
export class ButtonComponent implements ButtonLikeAbstraction {
@HostBinding("class") get classList() {
@@ -64,24 +69,41 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"tw-no-underline",
"hover:tw-no-underline",
"focus:tw-outline-none",
"disabled:tw-bg-secondary-300",
"disabled:hover:tw-bg-secondary-300",
"disabled:tw-border-secondary-300",
"disabled:hover:tw-border-secondary-300",
"disabled:!tw-text-muted",
"disabled:hover:!tw-text-muted",
"disabled:tw-cursor-not-allowed",
"disabled:hover:tw-no-underline",
]
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
.concat(buttonStyles[this.buttonType ?? "secondary"]);
.concat(buttonStyles[this.buttonType ?? "secondary"])
.concat(
this.showDisabledStyles() || this.disabled()
? [
"disabled:tw-bg-secondary-300",
"disabled:hover:tw-bg-secondary-300",
"disabled:tw-border-secondary-300",
"disabled:hover:tw-border-secondary-300",
"disabled:!tw-text-muted",
"disabled:hover:!tw-text-muted",
"disabled:tw-cursor-not-allowed",
"disabled:hover:tw-no-underline",
]
: [],
);
}
@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
protected disabledAttr = computed(() => {
const disabled = this.disabled() != null && this.disabled() !== false;
return disabled || this.loading() ? true : null;
});
/**
* Determine whether it is appropriate to display the disabled styles. We only want to show
* the disabled styles if the button is truly disabled, or if the loading styles are also
* visible.
*
* We can't use `disabledAttr` for this, because it returns `true` when `loading` is `true`.
* We only want to show disabled styles during loading if `showLoadingStyles` is `true`.
*/
protected showDisabledStyles = computed(() => {
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
});
@Input() buttonType: ButtonType;
@@ -96,7 +118,23 @@ export class ButtonComponent implements ButtonLikeAbstraction {
this._block = coerceBooleanProperty(value);
}
@Input() loading = false;
loading = model<boolean>(false);
@Input() disabled = false;
/**
* Determine whether it is appropriate to display a loading spinner. We only want to show
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents
* a spinner "flash" for actions that are synchronous/nearly synchronous.
*
* We can't use `loading` for this, because we still need to disable the button during
* the full `loading` state. I.e. we only want the spinner to be debounced, not the
* loading state.
*
* This pattern of converting a signal to an observable and back to a signal is not
* recommended. TODO -- find better way to use debounce with signals (CL-596)
*/
protected showLoadingStyle = toSignal(
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
);
disabled = model<boolean>(false);
}

View File

@@ -3,10 +3,12 @@
@fadeIn
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
} @else {
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
@if (!hideIcon()) {
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
} @else {
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
}
}
<h1
bitDialogTitleContainer

View File

@@ -1,4 +1,4 @@
import { Component, ContentChild, Directive } from "@angular/core";
import { booleanAttribute, Component, ContentChild, Directive, input } from "@angular/core";
import { TypographyDirective } from "../../typography/typography.directive";
import { fadeIn } from "../animations";
@@ -20,6 +20,11 @@ export class IconDirective {}
export class SimpleDialogComponent {
@ContentChild(IconDirective) icon!: IconDirective;
/**
* Optional flag to hide the dialog's center icon. Defaults to false.
*/
hideIcon = input(false, { transform: booleanAttribute });
get hasIcon() {
return this.icon != null;
}

View File

@@ -1,5 +1,5 @@
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { ButtonModule } from "../../button";
import { DialogModule } from "../dialog.module";
@@ -57,8 +57,24 @@ export const CustomIcon: Story = {
}),
};
export const HideIcon: Story = {
render: (args) => ({
props: args,
template: `
<bit-simple-dialog hideIcon>
<span bitDialogTitle>Premium Subscription Available</span>
<span bitDialogContent> Message Content</span>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary">Yes</button>
<button bitButton buttonType="secondary">No</button>
</ng-container>
</bit-simple-dialog>
`,
}),
};
export const ScrollingContent: Story = {
render: (args: SimpleDialogComponent) => ({
render: (args) => ({
props: args,
template: `
<bit-simple-dialog>

View File

@@ -1,10 +1,10 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i
class="bwi bwi-spinner bwi-spin"

View File

@@ -1,7 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgClass } from "@angular/common";
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { Component, computed, ElementRef, HostBinding, Input, model } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounce, interval } from "rxjs";
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element";
@@ -34,9 +36,6 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,
],
main: [
@@ -46,9 +45,6 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-600",
"focus-visible:before:tw-ring-primary-600",
"disabled:!tw-text-secondary-300",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,
],
muted: [
@@ -60,11 +56,8 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-600",
"focus-visible:before:tw-ring-primary-600",
"disabled:!tw-text-secondary-300",
"aria-expanded:hover:tw-bg-secondary-700",
"aria-expanded:hover:tw-border-secondary-700",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,
],
primary: [
@@ -74,9 +67,6 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-primary-600",
"hover:tw-border-primary-600",
"focus-visible:before:tw-ring-primary-600",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-primary-600",
"disabled:hover:tw-bg-primary-600",
...focusRing,
],
secondary: [
@@ -86,10 +76,6 @@ const styles: Record<IconButtonType, string[]> = {
"hover:!tw-text-contrast",
"hover:tw-bg-text-muted",
"focus-visible:before:tw-ring-primary-600",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-text-muted",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-muted",
...focusRing,
],
danger: [
@@ -100,10 +86,6 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent",
"hover:tw-border-primary-600",
"focus-visible:before:tw-ring-primary-600",
"disabled:!tw-text-secondary-300",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-secondary-300",
...focusRing,
],
light: [
@@ -113,10 +95,48 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-alt2",
"focus-visible:before:tw-ring-text-alt2",
...focusRing,
],
unstyled: [],
};
const disabledStyles: Record<IconButtonType, string[]> = {
contrast: [
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
main: [
"disabled:!tw-text-secondary-300",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
muted: [
"disabled:!tw-text-secondary-300",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
primary: [
"disabled:tw-opacity-60",
"disabled:hover:tw-border-primary-600",
"disabled:hover:tw-bg-primary-600",
],
secondary: [
"disabled:tw-opacity-60",
"disabled:hover:tw-border-text-muted",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-muted",
],
danger: [
"disabled:!tw-text-secondary-300",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-secondary-300",
],
light: [
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,
],
unstyled: [],
};
@@ -137,11 +157,14 @@ const sizes: Record<IconButtonSize, string[]> = {
],
standalone: true,
imports: [NgClass],
host: {
"[attr.disabled]": "disabledAttr()",
},
})
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
@Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonType;
@Input() buttonType: IconButtonType = "main";
@Input() size: IconButtonSize = "default";
@@ -155,22 +178,51 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
"hover:tw-no-underline",
"focus:tw-outline-none",
]
.concat(styles[this.buttonType ?? "main"])
.concat(sizes[this.size]);
.concat(styles[this.buttonType])
.concat(sizes[this.size])
.concat(this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType] : []);
}
get iconClass() {
return [this.icon, "!tw-m-0"];
}
@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
protected disabledAttr = computed(() => {
const disabled = this.disabled() != null && this.disabled() !== false;
return disabled || this.loading() ? true : null;
});
@Input() loading = false;
@Input() disabled = false;
/**
* Determine whether it is appropriate to display the disabled styles. We only want to show
* the disabled styles if the button is truly disabled, or if the loading styles are also
* visible.
*
* We can't use `disabledAttr` for this, because it returns `true` when `loading` is `true`.
* We only want to show disabled styles during loading if `showLoadingStyles` is `true`.
*/
protected showDisabledStyles = computed(() => {
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
});
loading = model(false);
/**
* Determine whether it is appropriate to display a loading spinner. We only want to show
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents
* a spinner "flash" for actions that are synchronous/nearly synchronous.
*
* We can't use `loading` for this, because we still need to disable the button during
* the full `loading` state. I.e. we only want the spinner to be debounced, not the
* loading state.
*
* This pattern of converting a signal to an observable and back to a signal is not
* recommended. TODO -- find better way to use debounce with signals (CL-596)
*/
protected showLoadingStyle = toSignal(
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
);
disabled = model<boolean>(false);
getFocusTarget() {
return this.elementRef.nativeElement;

View File

@@ -1,11 +1,11 @@
<ng-template>
<section cdkTrapFocus cdkTrapFocusAutoCapture class="tw-relative" role="dialog" aria-modal="true">
<div class="tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-secondary-300">
<div class="tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300">
<div
class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-background tw-pb-4 tw-pt-2 tw-text-main"
>
<div class="tw-mb-1 tw-mr-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-pl-4">
<h2 class="tw-mb-0 tw-mt-1 tw-text-base tw-font-semibold">
<h2 bitTypography="h5" class="tw-mt-1 tw-font-semibold">
{{ title }}
</h2>
<button
@@ -14,9 +14,11 @@
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
(click)="closed.emit()"
size="small"
class="tw-mt-0.5"
></button>
</div>
<div class="tw-px-4">
<div bitTypography="body2" class="tw-px-4">
<ng-content></ng-content>
</div>
</div>

View File

@@ -5,11 +5,12 @@ import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "
import { IconButtonModule } from "../icon-button/icon-button.module";
import { SharedModule } from "../shared/shared.module";
import { TypographyModule } from "../typography";
@Component({
standalone: true,
selector: "bit-popover",
imports: [A11yModule, IconButtonModule, SharedModule],
imports: [A11yModule, IconButtonModule, SharedModule, TypographyModule],
templateUrl: "./popover.component.html",
exportAs: "popoverComponent",
})

View File

@@ -75,7 +75,7 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<div class="tw-mt-56">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -112,11 +112,33 @@ export const Open: Story = {
}),
};
export const OpenLongTitle: Story = {
render: (args) => ({
props: args,
template: `
<bit-popover [title]="'Example Title that is really long it wraps 2 lines'" #myPopover="popoverComponent">
<div>Lorem ipsum dolor <a href="#">adipisicing elit</a>.</div>
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li>Dolor sit amet consectetur</li>
<li>Esse labore veniam tempora</li>
<li>Adipisicing elit ipsum <a href="#">iustolaborum</a></li>
</ul>
</bit-popover>
<div class="tw-h-40">
<div class="cdk-overlay-pane bit-popover-right bit-popover-right-start">
<ng-container *ngTemplateOutlet="myPopover.templateRef"></ng-container>
</div>
</div>
`,
}),
};
export const InitiallyOpen: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<div class="tw-mt-56">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -142,7 +164,7 @@ export const RightStart: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<div class="tw-mt-56">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -165,7 +187,7 @@ export const RightCenter: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<div class="tw-mt-56">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -188,7 +210,7 @@ export const RightEnd: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<div class="tw-mt-56">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -211,7 +233,7 @@ export const LeftStart: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<div class="tw-mt-56 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -234,7 +256,7 @@ export const LeftCenter: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<div class="tw-mt-56 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -256,7 +278,7 @@ export const LeftEnd: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<div class="tw-mt-56 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -279,7 +301,7 @@ export const BelowStart: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -302,7 +324,7 @@ export const BelowCenter: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -325,7 +347,7 @@ export const BelowEnd: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -348,7 +370,7 @@ export const AboveStart: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -371,7 +393,7 @@ export const AboveCenter: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"
@@ -394,7 +416,7 @@ export const AboveEnd: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<div class="tw-mt-56 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600"

View File

@@ -1,8 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
import { ModelSignal } from "@angular/core";
// @ts-strict-ignore
export type ButtonType = "primary" | "secondary" | "danger" | "unstyled";
export abstract class ButtonLikeAbstraction {
loading: boolean;
disabled: boolean;
loading: ModelSignal<boolean>;
disabled: ModelSignal<boolean>;
}

View File

@@ -1 +1 @@
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";

View File

@@ -21,7 +21,8 @@
"paths": {
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"]
"@bitwarden/ui-common": ["../ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["../ui/common/src/setup-jest"]
},
"plugins": [
{

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {

View File

@@ -24,6 +24,7 @@
"@bitwarden/send-ui": ["../tools/send/send-ui/src"],
"@bitwarden/tools-card": ["../tools/card/src"],
"@bitwarden/ui-common": ["../ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["../ui/common/src/setup-jest"],
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault-export-ui": ["../tools/export/vault-export/vault-export-ui/src"],
"@bitwarden/vault": ["../vault/src"]

View File

@@ -1 +1 @@
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";

View File

@@ -120,11 +120,11 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
ngAfterViewInit(): void {
if (this.submitBtn) {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
this.submitBtn.loading = loading;
this.submitBtn.loading.set(loading);
});
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
this.submitBtn.disabled = disabled;
this.submitBtn.disabled.set(disabled);
});
}
}

View File

@@ -51,6 +51,8 @@ describe("SendListFiltersComponent", () => {
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
{ provide: AccountService, useValue: accountService },
],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownProperties: false,
}).compileComponents();
fixture = TestBed.createComponent(SendListFiltersComponent);

View File

@@ -1 +1 @@
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";

View File

@@ -11,5 +11,13 @@
"type": "git",
"url": "https://github.com/bitwarden/clients"
},
"license": "GPL-3.0"
"license": "GPL-3.0",
"exports": {
".": {
"import": "./src/index.ts"
},
"./setup-jest": {
"import": "./src/setup-jest.ts"
}
}
}

View File

@@ -0,0 +1,12 @@
import "jest-preset-angular/setup-jest";
import { getTestBed } from "@angular/core/testing";
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from "@angular/platform-browser-dynamic/testing";
getTestBed().resetTestEnvironment();
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -103,7 +103,7 @@ describe("CipherAttachmentsComponent", () => {
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
component.cipherId = "5555-444-3333" as CipherId;
component.submitBtn = {} as ButtonComponent;
component.submitBtn = TestBed.createComponent(ButtonComponent).componentInstance;
fixture.detectChanges();
});
@@ -134,34 +134,38 @@ describe("CipherAttachmentsComponent", () => {
describe("bitSubmit", () => {
beforeEach(() => {
component.submitBtn.disabled = undefined;
component.submitBtn.loading = undefined;
component.submitBtn.disabled.set(undefined);
component.submitBtn.loading.set(undefined);
});
it("updates sets initial state of the submit button", async () => {
await component.ngOnInit();
expect(component.submitBtn.disabled).toBe(true);
expect(component.submitBtn.disabled()).toBe(true);
});
it("sets submitBtn loading state", () => {
jest.useFakeTimers();
component.bitSubmit.loading = true;
expect(component.submitBtn.loading).toBe(true);
jest.runAllTimers();
expect(component.submitBtn.loading()).toBe(true);
component.bitSubmit.loading = false;
expect(component.submitBtn.loading).toBe(false);
expect(component.submitBtn.loading()).toBe(false);
});
it("sets submitBtn disabled state", () => {
component.bitSubmit.disabled = true;
expect(component.submitBtn.disabled).toBe(true);
expect(component.submitBtn.disabled()).toBe(true);
component.bitSubmit.disabled = false;
expect(component.submitBtn.disabled).toBe(false);
expect(component.submitBtn.disabled()).toBe(false);
});
});
@@ -169,7 +173,7 @@ describe("CipherAttachmentsComponent", () => {
let file: File;
beforeEach(() => {
component.submitBtn.disabled = undefined;
component.submitBtn.disabled.set(undefined);
file = new File([""], "attachment.txt", { type: "text/plain" });
const inputElement = fixture.debugElement.query(By.css("input[type=file]"));
@@ -189,7 +193,7 @@ describe("CipherAttachmentsComponent", () => {
});
it("updates disabled state of submit button", () => {
expect(component.submitBtn.disabled).toBe(false);
expect(component.submitBtn.disabled()).toBe(false);
});
});

View File

@@ -114,7 +114,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
return;
}
this.submitBtn.disabled = status !== "VALID";
this.submitBtn.disabled.set(status !== "VALID");
});
}
@@ -127,7 +127,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
// Update the initial state of the submit button
if (this.submitBtn) {
this.submitBtn.disabled = !this.attachmentForm.valid;
this.submitBtn.disabled.set(!this.attachmentForm.valid);
}
}
@@ -137,7 +137,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
return;
}
this.submitBtn.loading = loading;
this.submitBtn.loading.set(loading);
});
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => {
@@ -145,7 +145,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
return;
}
this.submitBtn.disabled = disabled;
this.submitBtn.disabled.set(disabled);
});
}

View File

@@ -144,11 +144,11 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
ngAfterViewInit(): void {
if (this.submitBtn) {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
this.submitBtn.loading = loading;
this.submitBtn.loading.set(loading);
});
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
this.submitBtn.disabled = disabled;
this.submitBtn.disabled.set(disabled);
});
}
}

View File

@@ -32,6 +32,8 @@ describe("CipherFormGeneratorComponent", () => {
await TestBed.configureTestingModule({
imports: [CipherFormGeneratorComponent],
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownProperties: false,
})
.overrideComponent(CipherFormGeneratorComponent, {
remove: { imports: [GeneratorModule] },

View File

@@ -27,7 +27,7 @@
[label]="userEmail$ | async"
></bit-option>
<bit-option
*ngFor="let org of config.organizations"
*ngFor="let org of organizations"
[value]="org.id"
[label]="org.name"
></bit-option>

View File

@@ -59,6 +59,9 @@ describe("ItemDetailsSectionComponent", () => {
initializedWithCachedCipher,
});
i18nService = mock<I18nService>();
i18nService.collator = {
compare: (a: string, b: string) => a.localeCompare(b),
} as Intl.Collator;
await TestBed.configureTestingModule({
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
@@ -184,16 +187,18 @@ describe("ItemDetailsSectionComponent", () => {
it("should allow ownership change if personal ownership is allowed and there is at least one organization", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.organizations = [{ id: "org1", name: "org1" } as Organization];
fixture.detectChanges();
expect(component.allowOwnershipChange).toBe(true);
});
it("should allow ownership change if personal ownership is not allowed but there is more than one organization", () => {
component.config.allowPersonalOwnership = false;
component.config.organizations = [
{ id: "org1" } as Organization,
{ id: "org2" } as Organization,
{ id: "org1", name: "org1" } as Organization,
{ id: "org2", name: "org2" } as Organization,
];
fixture.detectChanges();
expect(component.allowOwnershipChange).toBe(true);
});
});
@@ -206,7 +211,8 @@ describe("ItemDetailsSectionComponent", () => {
it("should return the first organization id if personal ownership is not allowed", () => {
component.config.allowPersonalOwnership = false;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.organizations = [{ id: "org1", name: "Organization 1" } as Organization];
fixture.detectChanges();
expect(component.defaultOwner).toBe("org1");
});
});
@@ -244,16 +250,19 @@ describe("ItemDetailsSectionComponent", () => {
describe("showOwnership", () => {
it("should return true if ownership change is allowed or in edit mode with at least one organization", () => {
component.config.allowPersonalOwnership = true;
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true);
expect(component.showOwnership).toBe(true);
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false);
component.config.mode = "edit";
component.config.organizations = [{ id: "org1" } as Organization];
fixture.detectChanges();
expect(component.showOwnership).toBe(true);
});
it("should hide the ownership control if showOwnership is false", async () => {
component.config.allowPersonalOwnership = true;
jest.spyOn(component, "showOwnership", "get").mockReturnValue(false);
fixture.detectChanges();
await fixture.whenStable();
@@ -264,6 +273,7 @@ describe("ItemDetailsSectionComponent", () => {
});
it("should show the ownership control if showOwnership is true", async () => {
component.config.allowPersonalOwnership = true;
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
@@ -322,8 +332,8 @@ describe("ItemDetailsSectionComponent", () => {
it("should select the first organization if personal ownership is not allowed", async () => {
component.config.allowPersonalOwnership = false;
component.config.organizations = [
{ id: "org1" } as Organization,
{ id: "org2" } as Organization,
{ id: "org1", name: "org1" } as Organization,
{ id: "org2", name: "org2" } as Organization,
];
component.originalCipherView = {
name: "cipher1",
@@ -517,4 +527,23 @@ describe("ItemDetailsSectionComponent", () => {
expect(component["readOnlyCollectionsNames"]).toEqual(["Collection 1", "Collection 3"]);
});
});
describe("organizationOptions", () => {
it("should sort the organizations by name", async () => {
component.config.mode = "edit";
component.config.organizations = [
{ id: "org2", name: "org2" } as Organization,
{ id: "org1", name: "org1" } as Organization,
];
component.originalCipherView = {} as CipherView;
await component.ngOnInit();
fixture.detectChanges();
const select = fixture.debugElement.query(By.directive(SelectComponent));
const { label } = select.componentInstance.items[0];
expect(label).toBe("org1");
});
});
});

View File

@@ -12,6 +12,7 @@ import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -74,6 +75,8 @@ export class ItemDetailsSectionComponent implements OnInit {
/** The email address associated with the active account */
protected userEmail$ = this.accountService.activeAccount$.pipe(map((account) => account.email));
protected organizations: Organization[] = [];
@Input({ required: true })
config: CipherFormConfig;
@@ -90,10 +93,6 @@ export class ItemDetailsSectionComponent implements OnInit {
return this.config.mode === "partial-edit";
}
get organizations(): Organization[] {
return this.config.organizations;
}
get allowPersonalOwnership() {
return this.config.allowPersonalOwnership;
}
@@ -186,6 +185,10 @@ export class ItemDetailsSectionComponent implements OnInit {
}
async ngOnInit() {
this.organizations = this.config.organizations.sort(
Utils.getSortFunction(this.i18nService, "name"),
);
if (!this.allowPersonalOwnership && this.organizations.length === 0) {
throw new Error("No organizations available for ownership.");
}

View File

@@ -104,7 +104,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
return;
}
this.submitBtn.loading = loading;
this.submitBtn.loading.set(loading);
});
}

View File

@@ -213,7 +213,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
return;
}
this.submitBtn.loading = loading;
this.submitBtn.loading.set(loading);
});
this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
@@ -221,7 +221,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
return;
}
this.submitBtn.disabled = disabled;
this.submitBtn.disabled.set(disabled);
});
}

View File

@@ -20,6 +20,6 @@
></vault-carousel-button>
</div>
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
<ng-template [cdkPortalOutlet] #tempSlideOutlet></ng-template>
<ng-template cdkPortalOutlet></ng-template>
</div>
</section>

View File

@@ -8,12 +8,12 @@ import {
ContentChildren,
ElementRef,
EventEmitter,
inject,
Input,
Output,
QueryList,
ViewChild,
ViewChildren,
inject,
} from "@angular/core";
import { ButtonModule } from "@bitwarden/components";
@@ -89,7 +89,7 @@ export class VaultCarouselComponent implements AfterViewInit {
this.slideChange.emit(index);
}
ngAfterViewInit(): void {
async ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.carouselButtons)
.withHorizontalOrientation("ltr")
.withWrap()
@@ -98,7 +98,7 @@ export class VaultCarouselComponent implements AfterViewInit {
// Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus.
this.keyManager.setFirstItemActive();
this.setMinHeightOfCarousel();
await this.setMinHeightOfCarousel();
}
/**
@@ -106,7 +106,7 @@ export class VaultCarouselComponent implements AfterViewInit {
* Render each slide in a temporary portal outlet to get the height of each slide
* and store the tallest slide height.
*/
private setMinHeightOfCarousel() {
private async setMinHeightOfCarousel() {
// Store the height of the carousel button element.
const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight;
@@ -121,13 +121,14 @@ export class VaultCarouselComponent implements AfterViewInit {
// to determine the height of the first slide.
let tallestSlideHeightPx = containerHeight - heightOfButtonsPx;
this.slides.forEach((slide, index) => {
// Skip the first slide, the height is accounted for above.
if (index === this.selectedIndex) {
return;
for (let i = 0; i < this.slides.length; i++) {
if (i === this.selectedIndex) {
continue;
}
this.tempSlideOutlet.attach(this.slides.get(i)!.content);
this.tempSlideOutlet.attach(slide.content);
// Wait for the slide to render. Otherwise, the previous slide may not have been removed from the DOM yet.
await new Promise(requestAnimationFrame);
// Store the height of the current slide if is larger than the current stored height;
if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) {
@@ -136,8 +137,7 @@ export class VaultCarouselComponent implements AfterViewInit {
// cleanup the outlet
this.tempSlideOutlet.detach();
});
}
// Set the min height of the entire carousel based on the largest slide.
this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`;
this.changeDetectorRef.detectChanges();

View File

@@ -0,0 +1,10 @@
import { NgModule } from "@angular/core";
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
import { VaultCarouselComponent } from "./carousel.component";
@NgModule({
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
exports: [VaultCarouselComponent, VaultCarouselSlideComponent],
})
export class VaultCarouselModule {}

View File

@@ -1 +1 @@
export { VaultCarouselComponent } from "./carousel.component";
export { VaultCarouselModule } from "./carousel.module";

View File

@@ -0,0 +1,62 @@
import {
DestroyRef,
Directive,
ElementRef,
HostBinding,
inject,
input,
OnInit,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, Observable } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { Theme } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
/**
* Directive that will switch the image source based on the currently applied theme.
*
* @example
* ```html
* <img src="light-image.png" appDarkImgSrc="dark-image.png" />
* ```
*/
@Directive({
selector: "[appDarkImgSrc]",
standalone: true,
})
export class DarkImageSourceDirective implements OnInit {
private themeService = inject(ThemeStateService);
private systemTheme$: Observable<Theme> = inject(SYSTEM_THEME_OBSERVABLE);
private el = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
/**
* The image source to use when the light theme is applied. Automatically assigned the value
* of the `<img>` src attribute.
*/
protected lightImgSrc: string | undefined;
/**
* The image source to use when the dark theme is applied.
*/
darkImgSrc = input.required<string>({ alias: "appDarkImgSrc" });
@HostBinding("attr.src") src: string | undefined;
ngOnInit() {
// Set the light image source from the element's current src attribute
this.lightImgSrc = this.el.nativeElement.getAttribute("src");
// Update the image source based on the active theme
combineLatest([this.themeService.selectedTheme$, this.systemTheme$])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(([theme, systemTheme]) => {
const appliedTheme = theme === "system" ? systemTheme : theme;
const isDark =
appliedTheme === "dark" || appliedTheme === "nord" || appliedTheme === "solarizedDark";
this.src = isDark ? this.darkImgSrc() : this.lightImgSrc;
});
}
}

View File

@@ -4,6 +4,7 @@ export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
export { OrgIconDirective } from "./components/org-icon.directive";
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
export { DarkImageSourceDirective } from "./components/dark-image-source.directive";
export * from "./utils/observable-utilities";
@@ -21,6 +22,7 @@ export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-de
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
export * from "./components/carousel";
export * as VaultIcons from "./icons";

View File

@@ -1,5 +1,5 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {