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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
</button>
|
||||
|
||||
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
|
||||
<span>{{ "needAnotherOptionV1" | i18n }}</span>
|
||||
<span>{{ "needAnotherOptionV1" | i18n }}</span
|
||||
>
|
||||
<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
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="tw-flex tw-mt-4">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
[block]="true"
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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$);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "core-js/proposals/explicit-resource-management";
|
||||
|
||||
import { webcrypto } from "crypto";
|
||||
|
||||
import { addCustomMatchers } from "./spec";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
import "jest-preset-angular/setup-jest";
|
||||
import "@bitwarden/ui-common/setup-jest";
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
import "jest-preset-angular/setup-jest";
|
||||
import "@bitwarden/ui-common/setup-jest";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1 +1 @@
|
||||
import "jest-preset-angular/setup-jest";
|
||||
import "@bitwarden/ui-common/setup-jest";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
libs/ui/common/src/setup-jest.ts
Normal file
12
libs/ui/common/src/setup-jest.ts
Normal 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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.loading = loading;
|
||||
this.submitBtn.loading.set(loading);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
10
libs/vault/src/components/carousel/carousel.module.ts
Normal file
10
libs/vault/src/components/carousel/carousel.module.ts
Normal 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 {}
|
||||
@@ -1 +1 @@
|
||||
export { VaultCarouselComponent } from "./carousel.component";
|
||||
export { VaultCarouselModule } from "./carousel.module";
|
||||
|
||||
62
libs/vault/src/components/dark-image-source.directive.ts
Normal file
62
libs/vault/src/components/dark-image-source.directive.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user