mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 18:13:29 +00:00
Merge branch 'main' into auth/pm-22723/policy-service-updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export * from "./auth.guard";
|
||||
export * from "./active-auth.guard";
|
||||
export * from "./lock.guard";
|
||||
export * from "./redirect.guard";
|
||||
export * from "./redirect/redirect.guard";
|
||||
export * from "./tde-decryption-required.guard";
|
||||
export * from "./unauth.guard";
|
||||
|
||||
53
libs/angular/src/auth/guards/redirect/README.md
Normal file
53
libs/angular/src/auth/guards/redirect/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Redirect Guard
|
||||
|
||||
The `redirectGuard` redirects the user based on their `AuthenticationStatus`. It is applied to the root route (`/`).
|
||||
|
||||
<br>
|
||||
|
||||
### Order of Operations
|
||||
|
||||
The `redirectGuard` will redirect the user based on the following checks, _in order_:
|
||||
|
||||
- **`AuthenticationStatus.LoggedOut`** → redirect to `/login`
|
||||
- **`AuthenticationStatus.Unlocked`** → redirect to `/vault`
|
||||
- **`AuthenticationStatus.Locked`**
|
||||
- **TDE Locked State** → redirect to `/login-initiated`
|
||||
- A user is in a TDE Locked State if they meet all 3 of the following conditions
|
||||
1. Auth status is `Locked`
|
||||
2. TDE is enabled
|
||||
3. User has never had a user key (that is, user has not unlocked/decrypted yet)
|
||||
- **Standard Locked State** → redirect to `/lock`
|
||||
|
||||
<br>
|
||||
|
||||
| Order | AuthenticationStatus | Redirect To |
|
||||
| ----- | ------------------------------------------------------------------------------- | ------------------ |
|
||||
| 1 | `LoggedOut` | `/login` |
|
||||
| 2 | `Unlocked` | `/vault` |
|
||||
| 3 | **TDE Locked State** <br> `Locked` + <br> `tdeEnabled` + <br> `!everHadUserKey` | `/login-initiated` |
|
||||
| 4 | **Standard Locked State** <br> `Locked` | `/lock` |
|
||||
|
||||
<br>
|
||||
|
||||
### Default Routes and Route Overrides
|
||||
|
||||
The default redirect routes are mapped to object properties:
|
||||
|
||||
```typescript
|
||||
const defaultRoutes: RedirectRoutes = {
|
||||
loggedIn: "/vault",
|
||||
loggedOut: "/login",
|
||||
locked: "/lock",
|
||||
notDecrypted: "/login-initiated",
|
||||
};
|
||||
```
|
||||
|
||||
But when applying the guard to the root route, the developer can override specific redirect routes by passing in a custom object. This is useful for subtle differences in client-specific routing:
|
||||
|
||||
```typescript
|
||||
// app-routing.module.ts (Browser Extension)
|
||||
{
|
||||
path: "",
|
||||
canActivate: [redirectGuard({ loggedIn: "/tabs/current"})],
|
||||
}
|
||||
```
|
||||
@@ -25,12 +25,14 @@ const defaultRoutes: RedirectRoutes = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard that consolidates all redirection logic, should be applied to root route.
|
||||
* Redirects the user to the appropriate route based on their `AuthenticationStatus`.
|
||||
* This guard should be applied to the root route.
|
||||
*
|
||||
* TODO: This should return Observable<boolean | UrlTree> once we can get rid of all the promises
|
||||
*/
|
||||
export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActivateFn {
|
||||
const routes = { ...defaultRoutes, ...overrides };
|
||||
|
||||
return async (route) => {
|
||||
const authService = inject(AuthService);
|
||||
const keyService = inject(KeyService);
|
||||
@@ -41,16 +43,21 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
|
||||
|
||||
const authStatus = await authService.getAuthStatus();
|
||||
|
||||
// Logged Out
|
||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
||||
return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
// Unlocked
|
||||
if (authStatus === AuthenticationStatus.Unlocked) {
|
||||
return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
|
||||
// login decryption options component.
|
||||
// Locked: TDE Locked State
|
||||
// - If user meets all 3 of the following conditions:
|
||||
// 1. Auth status is Locked
|
||||
// 2. TDE is enabled
|
||||
// 3. User has never had a user key (has not decrypted yet)
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
|
||||
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
|
||||
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
|
||||
@@ -64,6 +71,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
|
||||
return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
// Locked: Standard Locked State
|
||||
if (authStatus === AuthenticationStatus.Locked) {
|
||||
return router.createUrlTree([routes.locked], { queryParams: route.queryParams });
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DefaultAnonLayoutWrapperDataService,
|
||||
DefaultLoginApprovalComponentService,
|
||||
DefaultLoginComponentService,
|
||||
DefaultLoginDecryptionOptionsService,
|
||||
@@ -296,10 +294,15 @@ import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
|
||||
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DefaultAnonLayoutWrapperDataService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
GeneratorHistoryService,
|
||||
LocalGeneratorHistoryService,
|
||||
@@ -678,6 +681,11 @@ const safeProviders: SafeProvider[] = [
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RestrictedItemTypesService,
|
||||
useClass: RestrictedItemTypesService,
|
||||
deps: [ConfigService, AccountService, OrganizationServiceAbstraction, PolicyServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PasswordStrengthServiceAbstraction,
|
||||
useClass: PasswordStrengthService,
|
||||
|
||||
@@ -84,7 +84,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
showCardNumber = false;
|
||||
showCardCode = false;
|
||||
cipherType = CipherType;
|
||||
typeOptions: any[];
|
||||
cardBrandOptions: any[];
|
||||
cardExpMonthOptions: any[];
|
||||
identityTitleOptions: any[];
|
||||
@@ -139,13 +138,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected sdkService: SdkService,
|
||||
private sshImportPromptService: SshImportPromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
|
||||
{ name: i18nService.t("typeCard"), value: CipherType.Card },
|
||||
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
|
||||
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
|
||||
];
|
||||
|
||||
this.cardBrandOptions = [
|
||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||
{ name: "Visa", value: "Visa" },
|
||||
@@ -215,8 +207,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.writeableCollections = await this.loadCollections();
|
||||
this.canUseReprompt = await this.passwordRepromptService.enabled();
|
||||
|
||||
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
combineLatest,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
@@ -20,6 +22,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
isCipherViewRestricted,
|
||||
RestrictedItemTypesService,
|
||||
} from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
|
||||
|
||||
@Directive()
|
||||
export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
@@ -35,6 +42,19 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
organization: Organization;
|
||||
CipherType = CipherType;
|
||||
|
||||
protected itemTypes$ = this.restrictedItemTypesService.restricted$.pipe(
|
||||
map((restrictedItemTypes) =>
|
||||
// Filter out restricted item types
|
||||
CIPHER_MENU_ITEMS.filter(
|
||||
(itemType) =>
|
||||
!restrictedItemTypes.some(
|
||||
(restrictedType) => restrictedType.cipherType === itemType.type,
|
||||
),
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
protected searchPending = false;
|
||||
|
||||
/** Construct filters as an observable so it can be appended to the cipher stream. */
|
||||
@@ -62,6 +82,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
protected searchService: SearchService,
|
||||
protected cipherService: CipherService,
|
||||
protected accountService: AccountService,
|
||||
protected restrictedItemTypesService: RestrictedItemTypesService,
|
||||
) {
|
||||
this.subscribeToCiphers();
|
||||
}
|
||||
@@ -143,18 +164,22 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
this._searchText$,
|
||||
this._filter$,
|
||||
of(userId),
|
||||
this.restrictedItemTypesService.restricted$,
|
||||
]),
|
||||
),
|
||||
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
|
||||
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
|
||||
let allCiphers = indexedCiphers ?? [];
|
||||
const _failedCiphers = failedCiphers ?? [];
|
||||
|
||||
allCiphers = [..._failedCiphers, ...allCiphers];
|
||||
|
||||
const restrictedTypeFilter = (cipher: CipherView) =>
|
||||
!isCipherViewRestricted(cipher, restricted);
|
||||
|
||||
return this.searchService.searchCiphers(
|
||||
userId,
|
||||
searchText,
|
||||
[filter, this.deletedFilter],
|
||||
[filter, this.deletedFilter, restrictedTypeFilter],
|
||||
allCiphers,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { Observable, combineLatest, from, of } from "rxjs";
|
||||
import { catchError, map } from "rxjs/operators";
|
||||
import { catchError, switchMap } from "rxjs/operators";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeStatus, NudgeType } from "../nudges.service";
|
||||
@@ -21,6 +25,9 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
private logService = inject(LogService);
|
||||
private pinService = inject(PinServiceAbstraction);
|
||||
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private biometricStateService = inject(BiometricStateService);
|
||||
private policyService = inject(PolicyService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
|
||||
@@ -36,16 +43,45 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
of(Date.now() - THIRTY_DAYS_MS),
|
||||
from(this.pinService.isPinSet(userId)),
|
||||
from(this.vaultTimeoutSettingsService.isBiometricLockSet(userId)),
|
||||
this.biometricStateService.biometricUnlockEnabled$,
|
||||
this.organizationService.organizations$(userId),
|
||||
this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId),
|
||||
]).pipe(
|
||||
map(([profileCreationDate, status, profileCutoff, isPinSet, isBiometricLockSet]) => {
|
||||
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
|
||||
const hideNudge = profileOlderThanCutoff || isPinSet || isBiometricLockSet;
|
||||
return {
|
||||
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
|
||||
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
|
||||
};
|
||||
}),
|
||||
switchMap(
|
||||
async ([
|
||||
profileCreationDate,
|
||||
status,
|
||||
profileCutoff,
|
||||
isPinSet,
|
||||
biometricUnlockEnabled,
|
||||
organizations,
|
||||
policies,
|
||||
]) => {
|
||||
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
|
||||
|
||||
const hasOrgWithRemovePinPolicyOn = organizations.some((org) => {
|
||||
return policies.some(
|
||||
(p) => p.type === PolicyType.RemoveUnlockWithPin && p.organizationId === org.id,
|
||||
);
|
||||
});
|
||||
|
||||
const hideNudge =
|
||||
profileOlderThanCutoff ||
|
||||
isPinSet ||
|
||||
biometricUnlockEnabled ||
|
||||
hasOrgWithRemovePinPolicyOn;
|
||||
|
||||
const acctSecurityNudgeStatus = {
|
||||
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
|
||||
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
|
||||
};
|
||||
|
||||
if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) {
|
||||
await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId);
|
||||
}
|
||||
return acctSecurityNudgeStatus;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { firstValueFrom, of } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -13,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec";
|
||||
|
||||
@@ -91,6 +94,18 @@ describe("Vault Nudges Service", () => {
|
||||
provide: VaultTimeoutSettingsService,
|
||||
useValue: mock<VaultTimeoutSettingsService>(),
|
||||
},
|
||||
{
|
||||
provide: BiometricStateService,
|
||||
useValue: mock<BiometricStateService>(),
|
||||
},
|
||||
{
|
||||
provide: PolicyService,
|
||||
useValue: mock<PolicyService>(),
|
||||
},
|
||||
{
|
||||
provide: OrganizationService,
|
||||
useValue: mock<OrganizationService>(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export * from "./bitwarden-logo.icon";
|
||||
export * from "./bitwarden-shield.icon";
|
||||
export * from "./devices.icon";
|
||||
export * from "./lock.icon";
|
||||
export * from "./registration-check-email.icon";
|
||||
export * from "./user-lock.icon";
|
||||
export * from "./user-verification-biometrics-fingerprint.icon";
|
||||
export * from "./wave.icon";
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* This barrel file should only contain Angular exports
|
||||
*/
|
||||
|
||||
// anon layout
|
||||
export * from "./anon-layout/anon-layout.component";
|
||||
export * from "./anon-layout/anon-layout-wrapper.component";
|
||||
export * from "./anon-layout/anon-layout-wrapper-data.service";
|
||||
export * from "./anon-layout/default-anon-layout-wrapper-data.service";
|
||||
|
||||
// change password
|
||||
export * from "./change-password/change-password.component";
|
||||
export * from "./change-password/change-password.service.abstraction";
|
||||
|
||||
@@ -30,6 +30,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
@@ -40,8 +41,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
|
||||
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
|
||||
@@ -32,6 +32,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
import { VaultIcon, WaveIcon } from "../icons";
|
||||
|
||||
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
|
||||
|
||||
@@ -16,14 +16,13 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginSuccessHandlerService,
|
||||
PasswordLoginCredentials,
|
||||
} from "../../../common";
|
||||
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
|
||||
import {
|
||||
InputPasswordComponent,
|
||||
InputPasswordFlow,
|
||||
|
||||
@@ -14,18 +14,18 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
Icons,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginEmailService } from "../../../common";
|
||||
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
|
||||
import { RegistrationUserAddIcon } from "../../icons";
|
||||
import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon";
|
||||
import { RegistrationEnvSelectorComponent } from "../registration-env-selector/registration-env-selector.component";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
@@ -170,7 +170,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
|
||||
pageTitle: {
|
||||
key: "checkYourEmail",
|
||||
},
|
||||
pageIcon: RegistrationCheckEmailIcon,
|
||||
pageIcon: Icons.RegistrationCheckEmailIcon,
|
||||
});
|
||||
this.registrationStartStateChange.emit(this.state);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperData,
|
||||
AnonLayoutWrapperDataService,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
@@ -34,8 +36,6 @@ import {
|
||||
// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports
|
||||
import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests";
|
||||
import { LoginEmailService } from "../../../common";
|
||||
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutWrapperData } from "../../anon-layout/anon-layout-wrapper.component";
|
||||
|
||||
import { RegistrationStartComponent } from "./registration-start.component";
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
import { DialogService, ToastService, AnonLayoutWrapperDataService } from "@bitwarden/components";
|
||||
|
||||
import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component-cache.service";
|
||||
import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service";
|
||||
|
||||
@@ -41,6 +41,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
@@ -49,7 +50,6 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
import {
|
||||
TwoFactorAuthAuthenticatorIcon,
|
||||
TwoFactorAuthEmailIcon,
|
||||
|
||||
@@ -228,16 +228,6 @@ export abstract class ApiService {
|
||||
request: CipherBulkRestoreRequest,
|
||||
) => Promise<ListResponse<CipherResponse>>;
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
postCipherAttachmentLegacy: (id: string, data: FormData) => Promise<CipherResponse>;
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
postCipherAttachmentAdminLegacy: (id: string, data: FormData) => Promise<CipherResponse>;
|
||||
postCipherAttachment: (
|
||||
id: string,
|
||||
request: AttachmentRequest,
|
||||
|
||||
@@ -92,6 +92,27 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
describe("createCredential", () => {
|
||||
describe("Mapping params should handle variations in input formats", () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
["false", false],
|
||||
["", false],
|
||||
["true", true],
|
||||
])("requireResidentKey should handle %s as boolean %s", async (input, expected) => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { requireResidentKey: input as any },
|
||||
extensions: { credProps: true },
|
||||
});
|
||||
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, windowReference);
|
||||
|
||||
expect(result.extensions.credProps?.rk).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input parameters validation", () => {
|
||||
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
|
||||
it("should throw error if sameOriginWithAncestors is false", async () => {
|
||||
|
||||
@@ -483,11 +483,15 @@ function mapToMakeCredentialParams({
|
||||
type: credential.type,
|
||||
})) ?? [];
|
||||
|
||||
/**
|
||||
* Quirk: Accounts for the fact that some RP's mistakenly submits 'requireResidentKey' as a string
|
||||
*/
|
||||
const requireResidentKey =
|
||||
params.authenticatorSelection?.residentKey === "required" ||
|
||||
params.authenticatorSelection?.residentKey === "preferred" ||
|
||||
(params.authenticatorSelection?.residentKey === undefined &&
|
||||
params.authenticatorSelection?.requireResidentKey === true);
|
||||
(params.authenticatorSelection?.requireResidentKey === true ||
|
||||
(params.authenticatorSelection?.requireResidentKey as unknown as string) === "true"));
|
||||
|
||||
const requireUserVerification =
|
||||
params.authenticatorSelection?.userVerification === "required" ||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
import type {
|
||||
AssertCredentialResult,
|
||||
CreateCredentialResult,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class Fido2Utils {
|
||||
static createResultToJson(result: CreateCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authData,
|
||||
transports: result.transports,
|
||||
publicKey: result.publicKey,
|
||||
publicKeyAlgorithm: result.publicKeyAlgorithm,
|
||||
attestationObject: result.attestationObject,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: result.extensions,
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static getResultToJson(result: AssertCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authenticatorData,
|
||||
signature: result.signature,
|
||||
userHandle: result.userHandle,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: {},
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static bufferToString(bufferSource: BufferSource): string {
|
||||
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
|
||||
.replace(/\+/g, "-")
|
||||
|
||||
@@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -639,24 +639,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new AttachmentUploadDataResponse(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async postCipherAttachmentLegacy(id: string, data: FormData): Promise<CipherResponse> {
|
||||
const r = await this.send("POST", "/ciphers/" + id + "/attachment", data, true, true);
|
||||
return new CipherResponse(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async postCipherAttachmentAdminLegacy(id: string, data: FormData): Promise<CipherResponse> {
|
||||
const r = await this.send("POST", "/ciphers/" + id + "/attachment-admin", data, true, true);
|
||||
return new CipherResponse(r);
|
||||
}
|
||||
|
||||
deleteCipherAttachment(id: string, attachmentId: string): Promise<any> {
|
||||
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,15 @@ export class CardView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new CardView(), obj);
|
||||
const cardView = new CardView();
|
||||
|
||||
cardView.cardholderName = obj.cardholderName ?? null;
|
||||
cardView.brand = obj.brand ?? null;
|
||||
cardView.number = obj.number ?? null;
|
||||
cardView.expMonth = obj.expMonth ?? null;
|
||||
cardView.expYear = obj.expYear ?? null;
|
||||
cardView.code = obj.code ?? null;
|
||||
|
||||
return cardView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,27 @@ export class IdentityView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new IdentityView(), obj);
|
||||
const identityView = new IdentityView();
|
||||
|
||||
identityView.title = obj.title ?? null;
|
||||
identityView.firstName = obj.firstName ?? null;
|
||||
identityView.middleName = obj.middleName ?? null;
|
||||
identityView.lastName = obj.lastName ?? null;
|
||||
identityView.address1 = obj.address1 ?? null;
|
||||
identityView.address2 = obj.address2 ?? null;
|
||||
identityView.address3 = obj.address3 ?? null;
|
||||
identityView.city = obj.city ?? null;
|
||||
identityView.state = obj.state ?? null;
|
||||
identityView.postalCode = obj.postalCode ?? null;
|
||||
identityView.country = obj.country ?? null;
|
||||
identityView.company = obj.company ?? null;
|
||||
identityView.email = obj.email ?? null;
|
||||
identityView.phone = obj.phone ?? null;
|
||||
identityView.ssn = obj.ssn ?? null;
|
||||
identityView.username = obj.username ?? null;
|
||||
identityView.passportNumber = obj.passportNumber ?? null;
|
||||
identityView.licenseNumber = obj.licenseNumber ?? null;
|
||||
|
||||
return identityView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +116,18 @@ export class LoginView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const passwordRevisionDate =
|
||||
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
|
||||
const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
const loginView = new LoginView();
|
||||
|
||||
return Object.assign(new LoginView(), obj, {
|
||||
passwordRevisionDate,
|
||||
uris,
|
||||
});
|
||||
loginView.username = obj.username ?? null;
|
||||
loginView.password = obj.password ?? null;
|
||||
loginView.passwordRevisionDate =
|
||||
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
|
||||
loginView.totp = obj.totp ?? null;
|
||||
loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null;
|
||||
loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
// FIDO2 credentials are not decrypted here, they remain encrypted
|
||||
loginView.fido2Credentials = null;
|
||||
|
||||
return loginView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ export class SecureNoteView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new SecureNoteView(), obj);
|
||||
const secureNoteView = new SecureNoteView();
|
||||
secureNoteView.type = obj.type ?? null;
|
||||
|
||||
return secureNoteView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ export class SshKeyView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyFingerprint = obj.fingerprint;
|
||||
const sshKeyView = new SshKeyView();
|
||||
|
||||
return Object.assign(new SshKeyView(), obj, {
|
||||
keyFingerprint,
|
||||
});
|
||||
sshKeyView.privateKey = obj.privateKey ?? null;
|
||||
sshKeyView.publicKey = obj.publicKey ?? null;
|
||||
sshKeyView.keyFingerprint = obj.fingerprint ?? null;
|
||||
|
||||
return sshKeyView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node";
|
||||
|
||||
export class ServiceUtils {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FileUploadApiMethods,
|
||||
FileUploadService,
|
||||
} from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
@@ -47,18 +46,7 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
|
||||
this.generateMethods(uploadDataResponse, response, request.adminRequest),
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
(e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) ||
|
||||
(e as ErrorResponse).statusCode === 405
|
||||
) {
|
||||
response = await this.legacyServerAttachmentFileUpload(
|
||||
request.adminRequest,
|
||||
cipher.id,
|
||||
encFileName,
|
||||
encData,
|
||||
dataEncKey[1],
|
||||
);
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
throw e;
|
||||
@@ -113,50 +101,4 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async legacyServerAttachmentFileUpload(
|
||||
admin: boolean,
|
||||
cipherId: string,
|
||||
encFileName: EncString,
|
||||
encData: EncArrayBuffer,
|
||||
key: EncString,
|
||||
) {
|
||||
const fd = new FormData();
|
||||
try {
|
||||
const blob = new Blob([encData.buffer], { type: "application/octet-stream" });
|
||||
fd.append("key", key.encryptedString);
|
||||
fd.append("data", blob, encFileName.encryptedString);
|
||||
} catch (e) {
|
||||
if (Utils.isNode && !Utils.isBrowser) {
|
||||
fd.append("key", key.encryptedString);
|
||||
fd.append(
|
||||
"data",
|
||||
Buffer.from(encData.buffer) as any,
|
||||
{
|
||||
filepath: encFileName.encryptedString,
|
||||
contentType: "application/octet-stream",
|
||||
} as any,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let response: CipherResponse;
|
||||
try {
|
||||
if (admin) {
|
||||
response = await this.apiService.postCipherAttachmentAdminLegacy(cipherId, fd);
|
||||
} else {
|
||||
response = await this.apiService.postCipherAttachmentLegacy(cipherId, fd);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
@@ -49,19 +48,16 @@ describe("RestrictedItemTypesService", () => {
|
||||
fakeAccount = { id: Utils.newGuid() as UserId } as Account;
|
||||
accountService.activeAccount$ = of(fakeAccount);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: OrganizationService, useValue: organizationService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
});
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
service = TestBed.inject(RestrictedItemTypesService);
|
||||
|
||||
service = new RestrictedItemTypesService(
|
||||
configService,
|
||||
accountService,
|
||||
organizationService,
|
||||
policyService,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits empty array when feature flag is disabled", async () => {
|
||||
@@ -106,7 +102,6 @@ describe("RestrictedItemTypesService", () => {
|
||||
});
|
||||
|
||||
it("returns empty allowViewOrgIds when all orgs restrict the same type", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(of([policyOrg1, policyOrg2]));
|
||||
|
||||
@@ -117,7 +112,6 @@ describe("RestrictedItemTypesService", () => {
|
||||
});
|
||||
|
||||
it("aggregates multiple types and computes allowViewOrgIds correctly", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { combineLatest, map, of, Observable } from "rxjs";
|
||||
import { switchMap, distinctUntilChanged, shareReplay } from "rxjs/operators";
|
||||
|
||||
@@ -10,13 +9,13 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
export type RestrictedCipherType = {
|
||||
cipherType: CipherType;
|
||||
allowViewOrgIds: string[];
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class RestrictedItemTypesService {
|
||||
/**
|
||||
* Emits an array of RestrictedCipherType objects:
|
||||
@@ -78,3 +77,25 @@ export class RestrictedItemTypesService {
|
||||
private policyService: PolicyService,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter that returns whether a cipher is restricted from being viewed by the user
|
||||
* Criteria:
|
||||
* - the cipher's type is restricted by at least one org
|
||||
* UNLESS
|
||||
* - the cipher belongs to an organization and that organization does not restrict that type
|
||||
* OR
|
||||
* - the cipher belongs to the user's personal vault and at least one other organization does not restrict that type
|
||||
*/
|
||||
export function isCipherViewRestricted(
|
||||
cipher: CipherView,
|
||||
restrictedTypes: RestrictedCipherType[],
|
||||
) {
|
||||
return restrictedTypes.some(
|
||||
(restrictedType) =>
|
||||
restrictedType.cipherType === cipher.type &&
|
||||
(cipher.organizationId
|
||||
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
|
||||
: restrictedType.allowViewOrgIds.length === 0),
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,6 @@ export const CIPHER_MENU_ITEMS = Object.freeze([
|
||||
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
|
||||
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
|
||||
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
|
||||
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" },
|
||||
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "typeNote" },
|
||||
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
|
||||
] as const) satisfies readonly CipherMenuItem[];
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
|
||||
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Icon, Translation } from "@bitwarden/components";
|
||||
|
||||
import { Translation } from "../dialog";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
|
||||
export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
@@ -14,24 +14,19 @@ import {
|
||||
EnvironmentService,
|
||||
Environment,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
|
||||
// FIXME: remove `/apps` import from `/libs`
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports
|
||||
import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests";
|
||||
import { LockIcon } from "../icons";
|
||||
import { RegistrationCheckEmailIcon } from "../icons/registration-check-email.icon";
|
||||
import { ButtonModule } from "../button";
|
||||
import { LockIcon, RegistrationCheckEmailIcon } from "../icon/icons";
|
||||
import { I18nMockService } from "../utils";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
|
||||
import { DefaultAnonLayoutWrapperDataService } from "./default-anon-layout-wrapper-data.service";
|
||||
|
||||
export default {
|
||||
title: "Auth/Anon Layout Wrapper",
|
||||
title: "Component Library/Anon Layout Wrapper",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
} as Meta;
|
||||
|
||||
@@ -84,13 +79,21 @@ const decorators = (options: {
|
||||
getClientType: () => options.clientType || ClientType.Web,
|
||||
} as Partial<PlatformUtilsService>,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
setAStrongPassword: "Set a strong password",
|
||||
appLogoLabel: "app logo label",
|
||||
finishCreatingYourAccountBySettingAPassword:
|
||||
"Finish creating your account by setting a password",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
importProvidersFrom(RouterModule.forRoot(options.routes)),
|
||||
importProvidersFrom(PreloadedEnglishI18nModule),
|
||||
],
|
||||
providers: [importProvidersFrom(RouterModule.forRoot(options.routes))],
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -102,18 +105,21 @@ type Story = StoryObj<AnonLayoutWrapperComponent>;
|
||||
@Component({
|
||||
selector: "bit-default-primary-outlet-example-component",
|
||||
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultPrimaryOutletExampleComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "bit-default-secondary-outlet-example-component",
|
||||
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultSecondaryOutletExampleComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "bit-default-env-selector-outlet-example-component",
|
||||
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultEnvSelectorOutletExampleComponent {}
|
||||
|
||||
@@ -188,6 +194,7 @@ const changedData: AnonLayoutWrapperData = {
|
||||
template: `
|
||||
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
|
||||
`,
|
||||
standalone: false,
|
||||
})
|
||||
export class DynamicContentExampleComponent {
|
||||
initialData = true;
|
||||
@@ -9,16 +9,10 @@ import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { IconModule, Icon } from "../../../../components/src/icon";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { SharedModule } from "../../../../components/src/shared";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { TypographyModule } from "../../../../components/src/typography";
|
||||
import { BitwardenLogo, BitwardenShield } from "../icons";
|
||||
import { IconModule, Icon } from "../icon";
|
||||
import { BitwardenLogo, BitwardenShield } from "../icon/icons";
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
@Component({
|
||||
selector: "auth-anon-layout",
|
||||
@@ -6,8 +6,8 @@ import * as stories from "./anon-layout.stories";
|
||||
|
||||
# AnonLayout Component
|
||||
|
||||
The Auth-owned AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we
|
||||
don't know who the user is.
|
||||
The AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we don't know who
|
||||
the user is.
|
||||
|
||||
\*There will be a few exceptions to this—that is, AnonLayout will also be used for the Unlock
|
||||
and View Send pages.
|
||||
@@ -7,13 +7,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ButtonModule } from "../../../../components/src/button";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { I18nMockService } from "../../../../components/src/utils/i18n-mock.service";
|
||||
import { LockIcon } from "../icons";
|
||||
import { ButtonModule } from "../button";
|
||||
import { LockIcon } from "../icon/icons";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
|
||||
@@ -23,7 +19,7 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Auth/Anon Layout",
|
||||
title: "Component Library/Anon Layout",
|
||||
component: AnonLayoutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
@@ -38,6 +34,7 @@ export default {
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
accessing: "Accessing",
|
||||
appLogoLabel: "app logo label",
|
||||
});
|
||||
},
|
||||
},
|
||||
4
libs/components/src/anon-layout/index.ts
Normal file
4
libs/components/src/anon-layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./anon-layout-wrapper-data.service";
|
||||
export * from "./anon-layout-wrapper.component";
|
||||
export * from "./anon-layout.component";
|
||||
export * from "./default-anon-layout-wrapper-data.service";
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, ElementRef, ViewChild } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ToastService } from "../";
|
||||
import { ToastService, CopyClickListener, COPY_CLICK_LISTENER } from "../";
|
||||
|
||||
import { CopyClickDirective } from "./copy-click.directive";
|
||||
|
||||
@@ -34,10 +35,12 @@ describe("CopyClickDirective", () => {
|
||||
let fixture: ComponentFixture<TestCopyClickComponent>;
|
||||
const copyToClipboard = jest.fn();
|
||||
const showToast = jest.fn();
|
||||
const copyClickListener = mock<CopyClickListener>();
|
||||
|
||||
beforeEach(async () => {
|
||||
copyToClipboard.mockClear();
|
||||
showToast.mockClear();
|
||||
copyClickListener.onCopy.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestCopyClickComponent],
|
||||
@@ -55,6 +58,7 @@ describe("CopyClickDirective", () => {
|
||||
},
|
||||
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
|
||||
{ provide: ToastService, useValue: { showToast } },
|
||||
{ provide: COPY_CLICK_LISTENER, useValue: copyClickListener },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -92,7 +96,6 @@ describe("CopyClickDirective", () => {
|
||||
successToastButton.click();
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "copySuccessful",
|
||||
title: null,
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
@@ -103,7 +106,6 @@ describe("CopyClickDirective", () => {
|
||||
infoToastButton.click();
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "copySuccessful",
|
||||
title: null,
|
||||
variant: "info",
|
||||
});
|
||||
});
|
||||
@@ -115,8 +117,15 @@ describe("CopyClickDirective", () => {
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "valueCopied Content",
|
||||
title: null,
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("should call copyClickListener.onCopy when value is copied", () => {
|
||||
const successToastButton = fixture.componentInstance.successToastButton.nativeElement;
|
||||
|
||||
successToastButton.click();
|
||||
|
||||
expect(copyClickListener.onCopy).toHaveBeenCalledWith("success toast shown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, HostListener, Input } from "@angular/core";
|
||||
import { Directive, HostListener, Input, InjectionToken, Inject, Optional } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ToastService, ToastVariant } from "../";
|
||||
|
||||
/**
|
||||
* Listener that can be provided to receive copy events to allow for customized behavior.
|
||||
*/
|
||||
export interface CopyClickListener {
|
||||
onCopy(value: string): void;
|
||||
}
|
||||
|
||||
export const COPY_CLICK_LISTENER = new InjectionToken<CopyClickListener>("CopyClickListener");
|
||||
|
||||
@Directive({
|
||||
selector: "[appCopyClick]",
|
||||
})
|
||||
@@ -18,6 +25,7 @@ export class CopyClickDirective {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
@Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener,
|
||||
) {}
|
||||
|
||||
@Input("appCopyClick") valueToCopy = "";
|
||||
@@ -26,7 +34,7 @@ export class CopyClickDirective {
|
||||
* When set, the toast displayed will show `<valueLabel> copied`
|
||||
* instead of the default messaging.
|
||||
*/
|
||||
@Input() valueLabel: string;
|
||||
@Input() valueLabel?: string;
|
||||
|
||||
/**
|
||||
* When set without a value, a success toast will be shown when the value is copied
|
||||
@@ -54,6 +62,10 @@ export class CopyClickDirective {
|
||||
@HostListener("click") onClick() {
|
||||
this.platformUtilsService.copyToClipboard(this.valueToCopy);
|
||||
|
||||
if (this.copyListener) {
|
||||
this.copyListener.onCopy(this.valueToCopy);
|
||||
}
|
||||
|
||||
if (this._showToast) {
|
||||
const message = this.valueLabel
|
||||
? this.i18nService.t("valueCopied", this.valueLabel)
|
||||
@@ -61,7 +73,6 @@ export class CopyClickDirective {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: this.toastVariant,
|
||||
title: null,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { provideAnimations } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { NoopAnimationsModule, provideAnimations } from "@angular/platform-browser/animations";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import { getAllByRole, userEvent } from "@storybook/test";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { LayoutComponent } from "../layout";
|
||||
import { SharedModule } from "../shared";
|
||||
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { DialogModule } from "./dialog.module";
|
||||
@@ -16,7 +22,12 @@ interface Animal {
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<button bitButton type="button" (click)="openDialog()">Open Dialog</button>`,
|
||||
template: `
|
||||
<bit-layout>
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
|
||||
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
|
||||
</bit-layout>
|
||||
`,
|
||||
imports: [ButtonModule],
|
||||
})
|
||||
class StoryDialogComponent {
|
||||
@@ -29,6 +40,14 @@ class StoryDialogComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.dialogService.openDrawer(StoryDialogContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -64,7 +83,21 @@ export default {
|
||||
title: "Component Library/Dialogs/Service",
|
||||
component: StoryDialogComponent,
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
declarations: [StoryDialogContentComponent],
|
||||
imports: [
|
||||
SharedModule,
|
||||
ButtonModule,
|
||||
NoopAnimationsModule,
|
||||
DialogModule,
|
||||
IconButtonModule,
|
||||
RouterTestingModule,
|
||||
LayoutComponent,
|
||||
],
|
||||
providers: [DialogService],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideAnimations(),
|
||||
DialogService,
|
||||
@@ -73,7 +106,13 @@ export default {
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
loading: "Loading",
|
||||
search: "Search",
|
||||
skipToContent: "Skip to content",
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
toggleSideNavigation: "Toggle side navigation",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -90,4 +129,21 @@ export default {
|
||||
|
||||
type Story = StoryObj<StoryDialogComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[0];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
/** Drawers must be a descendant of `bit-layout`. */
|
||||
export const Drawer: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[1];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
DEFAULT_DIALOG_CONFIG,
|
||||
Dialog,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DIALOG_SCROLL_STRATEGY,
|
||||
Dialog as CdkDialog,
|
||||
DialogConfig as CdkDialogConfig,
|
||||
DialogRef as CdkDialogRefBase,
|
||||
DIALOG_DATA,
|
||||
DialogCloseOptions,
|
||||
} from "@angular/cdk/dialog";
|
||||
import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
Optional,
|
||||
SkipSelf,
|
||||
TemplateRef,
|
||||
} from "@angular/core";
|
||||
import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay";
|
||||
import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
|
||||
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
|
||||
import { SimpleDialogOptions, Translation } from "./simple-dialog/types";
|
||||
import { SimpleDialogOptions } from "./simple-dialog/types";
|
||||
|
||||
/**
|
||||
* The default `BlockScrollStrategy` does not work well with virtual scrolling.
|
||||
@@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
|
||||
detach() {}
|
||||
}
|
||||
|
||||
export abstract class DialogRef<R = unknown, C = unknown>
|
||||
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
|
||||
{
|
||||
abstract readonly isDrawer?: boolean;
|
||||
|
||||
// --- From CdkDialogRef ---
|
||||
abstract close(result?: R, options?: DialogCloseOptions): void;
|
||||
abstract readonly closed: Observable<R | undefined>;
|
||||
abstract disableClose: boolean | undefined;
|
||||
/**
|
||||
* @deprecated
|
||||
* Does not work with drawer dialogs.
|
||||
**/
|
||||
abstract componentInstance: C | null;
|
||||
}
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
>;
|
||||
|
||||
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = true;
|
||||
|
||||
private _closed = new Subject<R | undefined>();
|
||||
closed = this._closed.asObservable();
|
||||
disableClose = false;
|
||||
|
||||
/** The portal containing the drawer */
|
||||
portal?: Portal<unknown>;
|
||||
|
||||
constructor(private drawerService: DrawerService) {}
|
||||
|
||||
close(result?: R, _options?: DialogCloseOptions): void {
|
||||
if (this.disableClose) {
|
||||
return;
|
||||
}
|
||||
this.drawerService.close(this.portal!);
|
||||
this._closed.next(result);
|
||||
this._closed.complete();
|
||||
}
|
||||
|
||||
componentInstance: C | null = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DialogRef that delegates functionality to the CDK implementation
|
||||
**/
|
||||
export class CdkDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
readonly isDrawer = false;
|
||||
|
||||
/** This is not available until after construction, @see DialogService.open. */
|
||||
cdkDialogRefBase!: CdkDialogRefBase<R, C>;
|
||||
|
||||
// --- Delegated to CdkDialogRefBase ---
|
||||
|
||||
close(result?: R, options?: DialogCloseOptions): void {
|
||||
this.cdkDialogRefBase.close(result, options);
|
||||
}
|
||||
|
||||
get closed(): Observable<R | undefined> {
|
||||
return this.cdkDialogRefBase.closed;
|
||||
}
|
||||
|
||||
get disableClose(): boolean | undefined {
|
||||
return this.cdkDialogRefBase.disableClose;
|
||||
}
|
||||
set disableClose(value: boolean | undefined) {
|
||||
this.cdkDialogRefBase.disableClose = value;
|
||||
}
|
||||
|
||||
// Delegate the `componentInstance` property to the CDK DialogRef
|
||||
get componentInstance(): C | null {
|
||||
return this.cdkDialogRefBase.componentInstance;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DialogService extends Dialog implements OnDestroy {
|
||||
private _destroy$ = new Subject<void>();
|
||||
export class DialogService {
|
||||
private dialog = inject(CdkDialog);
|
||||
private drawerService = inject(DrawerService);
|
||||
private injector = inject(Injector);
|
||||
private router = inject(Router, { optional: true });
|
||||
private authService = inject(AuthService, { optional: true });
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
|
||||
|
||||
private defaultScrollStrategy = new CustomBlockScrollStrategy();
|
||||
private activeDrawer: DrawerDialogRef<any, any> | null = null;
|
||||
|
||||
constructor(
|
||||
/** Parent class constructor */
|
||||
_overlay: Overlay,
|
||||
_injector: Injector,
|
||||
@Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig,
|
||||
@Optional() @SkipSelf() _parentDialog: Dialog,
|
||||
_overlayContainer: OverlayContainer,
|
||||
@Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any,
|
||||
|
||||
/** Not in parent class */
|
||||
@Optional() router: Router,
|
||||
@Optional() authService: AuthService,
|
||||
|
||||
protected i18nService: I18nService,
|
||||
) {
|
||||
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* TODO: This logic should exist outside of `libs/components`.
|
||||
* @see https://bitwarden.atlassian.net/browse/CL-657
|
||||
**/
|
||||
/** Close all open dialogs if the vault locks */
|
||||
if (router && authService) {
|
||||
router.events
|
||||
if (this.router && this.authService) {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
switchMap(() => authService.getAuthStatus()),
|
||||
switchMap(() => this.authService!.getAuthStatus()),
|
||||
filter((v) => v !== AuthenticationStatus.Unlocked),
|
||||
takeUntil(this._destroy$),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => this.closeAll());
|
||||
}
|
||||
}
|
||||
|
||||
override ngOnDestroy(): void {
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
override open<R = unknown, D = unknown, C = unknown>(
|
||||
open<R = unknown, D = unknown, C = unknown>(
|
||||
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
config = {
|
||||
/**
|
||||
* This is a bit circular in nature:
|
||||
* We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`,
|
||||
* but we get the base CDK DialogRef instance *from* `Dialog.open`.
|
||||
*
|
||||
* To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase.
|
||||
* This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance".
|
||||
**/
|
||||
const ref = new CdkDialogRef<R, C>();
|
||||
const injector = this.createInjector({
|
||||
data: config?.data,
|
||||
dialogRef: ref,
|
||||
});
|
||||
|
||||
// Merge the custom config with the default config
|
||||
const _config = {
|
||||
backdropClass: this.backDropClasses,
|
||||
scrollStrategy: this.defaultScrollStrategy,
|
||||
injector,
|
||||
...config,
|
||||
};
|
||||
|
||||
return super.open(componentOrTemplateRef, config);
|
||||
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** Opens a dialog in the side drawer */
|
||||
openDrawer<R = unknown, D = unknown, C = unknown>(
|
||||
component: ComponentType<C>,
|
||||
config?: DialogConfig<D, DialogRef<R, C>>,
|
||||
): DialogRef<R, C> {
|
||||
this.activeDrawer?.close();
|
||||
/**
|
||||
* This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector.
|
||||
* Similar to `this.open`, we get around this with mutability.
|
||||
*/
|
||||
this.activeDrawer = new DrawerDialogRef(this.drawerService);
|
||||
const portal = new ComponentPortal(
|
||||
component,
|
||||
null,
|
||||
this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }),
|
||||
);
|
||||
this.activeDrawer.portal = portal;
|
||||
this.drawerService.open(portal);
|
||||
return this.activeDrawer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy {
|
||||
*/
|
||||
async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise<boolean> {
|
||||
const dialogRef = this.openSimpleDialogRef(simpleDialogOptions);
|
||||
|
||||
return firstValueFrom(dialogRef.closed);
|
||||
return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
protected translate(translation: string | Translation, defaultKey?: string): string {
|
||||
if (translation == null && defaultKey == null) {
|
||||
return null;
|
||||
}
|
||||
/** Close all open dialogs */
|
||||
closeAll(): void {
|
||||
return this.dialog.closeAll();
|
||||
}
|
||||
|
||||
if (translation == null) {
|
||||
return this.i18nService.t(defaultKey);
|
||||
}
|
||||
|
||||
// Translation interface use implies we must localize.
|
||||
if (typeof translation === "object") {
|
||||
return this.i18nService.t(translation.key, ...(translation.placeholders ?? []));
|
||||
}
|
||||
|
||||
return translation;
|
||||
/** The injector that is passed to the opened dialog */
|
||||
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
|
||||
return Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: opts.data,
|
||||
},
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: opts.dialogRef,
|
||||
},
|
||||
{
|
||||
provide: CdkDialogRefBase,
|
||||
useValue: opts.dialogRef,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="width"
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
|
||||
@fadeIn
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-6 tw-pb-4': isDrawer,
|
||||
'tw-border-secondary-300': showHeaderBorder,
|
||||
'tw-border-transparent': !showHeaderBorder,
|
||||
}"
|
||||
>
|
||||
<h1
|
||||
<h2
|
||||
bitDialogTitleContainer
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
@@ -19,7 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h1>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
@@ -32,9 +42,11 @@
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
|
||||
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
|
||||
[ngClass]="{
|
||||
'tw-min-h-60': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
@if (loading) {
|
||||
@@ -43,20 +55,28 @@
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
cdkScrollable
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding,
|
||||
'tw-p-4': !disablePadding && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
|
||||
'tw-overflow-y-auto': !loading,
|
||||
'tw-invisible tw-overflow-y-hidden': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-border-secondary-300': showFooterBorder,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
</footer>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { fadeIn } from "../animations";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
|
||||
@@ -16,6 +20,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
selector: "bit-dialog",
|
||||
templateUrl: "./dialog.component.html",
|
||||
animations: [fadeIn],
|
||||
host: {
|
||||
"(keydown.esc)": "handleEsc($event)",
|
||||
},
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogTitleContainerDirective,
|
||||
@@ -23,9 +30,15 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
BitIconButtonComponent,
|
||||
DialogCloseDirective,
|
||||
I18nPipe,
|
||||
CdkTrapFocus,
|
||||
CdkScrollable,
|
||||
],
|
||||
})
|
||||
export class DialogComponent {
|
||||
protected dialogRef = inject(DialogRef, { optional: true });
|
||||
private scrollableBody = viewChild.required(CdkScrollable);
|
||||
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
|
||||
|
||||
/** Background color */
|
||||
@Input()
|
||||
background: "default" | "alt" = "default";
|
||||
@@ -63,21 +76,31 @@ export class DialogComponent {
|
||||
|
||||
@HostBinding("class") get classes() {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
return ["tw-flex", "tw-flex-col", "tw-w-screen", "tw-p-4", "tw-max-h-[90vh]"].concat(
|
||||
this.width,
|
||||
);
|
||||
return ["tw-flex", "tw-flex-col", "tw-w-screen"]
|
||||
.concat(
|
||||
this.width,
|
||||
this.dialogRef?.isDrawer
|
||||
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
|
||||
: ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"],
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
|
||||
handleEsc(event: Event) {
|
||||
this.dialogRef?.close();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
get width() {
|
||||
switch (this.dialogSize) {
|
||||
case "small": {
|
||||
return "tw-max-w-sm";
|
||||
return "md:tw-max-w-sm";
|
||||
}
|
||||
case "large": {
|
||||
return "tw-max-w-3xl";
|
||||
return "md:tw-max-w-3xl";
|
||||
}
|
||||
default: {
|
||||
return "tw-max-w-xl";
|
||||
return "md:tw-max-w-xl";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ For alerts or simple confirmation actions, like speedbumps, use the
|
||||
Dialogs's should be used sparingly as they do call extra attention to themselves and can be
|
||||
interruptive if overused.
|
||||
|
||||
For non-blocking, supplementary content, open dialogs as a
|
||||
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
|
||||
|
||||
## Placement
|
||||
|
||||
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./dialog.module";
|
||||
export * from "./simple-dialog/types";
|
||||
export * from "./dialog.service";
|
||||
export { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
export { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map } from "rxjs";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { hasScrolledFrom } from "../utils/has-scrolled-from";
|
||||
|
||||
/**
|
||||
* Body container for `bit-drawer`
|
||||
@@ -13,7 +13,7 @@ import { map } from "rxjs";
|
||||
host: {
|
||||
class:
|
||||
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
|
||||
"[class.tw-border-t-secondary-300]": "isScrolled()",
|
||||
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
|
||||
},
|
||||
hostDirectives: [
|
||||
{
|
||||
@@ -23,13 +23,5 @@ import { map } from "rxjs";
|
||||
template: ` <ng-content></ng-content> `,
|
||||
})
|
||||
export class DrawerBodyComponent {
|
||||
private scrollable = inject(CdkScrollable);
|
||||
|
||||
/** TODO: share this utility with browser popup header? */
|
||||
protected isScrolled: Signal<boolean> = toSignal(
|
||||
this.scrollable
|
||||
.elementScrolled()
|
||||
.pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
protected hasScrolledFrom = hasScrolledFrom();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { DrawerHostDirective } from "./drawer-host.directive";
|
||||
import { DrawerService } from "./drawer.service";
|
||||
|
||||
/**
|
||||
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
@@ -24,7 +24,7 @@ import { DrawerHostDirective } from "./drawer-host.directive";
|
||||
templateUrl: "drawer.component.html",
|
||||
})
|
||||
export class DrawerComponent {
|
||||
private drawerHost = inject(DrawerHostDirective);
|
||||
private drawerHost = inject(DrawerService);
|
||||
private portal = viewChild.required(CdkPortal);
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DrawerComponent } from "@bitwarden/components";
|
||||
|
||||
# Drawer
|
||||
|
||||
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
|
||||
|
||||
A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
|
||||
<Primary />
|
||||
|
||||
20
libs/components/src/drawer/drawer.service.ts
Normal file
20
libs/components/src/drawer/drawer.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DrawerService {
|
||||
private _portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/** The portal to display */
|
||||
portal = this._portal.asReadonly();
|
||||
|
||||
open(portal: Portal<unknown>) {
|
||||
this._portal.set(portal);
|
||||
}
|
||||
|
||||
close(portal: Portal<unknown>) {
|
||||
if (portal === this.portal()) {
|
||||
this._portal.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
import { svgIcon } from "../icon";
|
||||
|
||||
export const BitwardenLogo = svgIcon`
|
||||
<svg viewBox="0 0 290 45" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
import { svgIcon } from "../icon";
|
||||
|
||||
export const BitwardenShield = svgIcon`
|
||||
<svg viewBox="0 0 120 132" xmlns="http://www.w3.org/2000/svg">
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,13 @@
|
||||
export * from "./search";
|
||||
export * from "./security";
|
||||
export * from "./bitwarden-logo.icon";
|
||||
export * from "./bitwarden-shield.icon";
|
||||
export * from "./extension-bitwarden-logo.icon";
|
||||
export * from "./lock.icon";
|
||||
export * from "./generator";
|
||||
export * from "./no-access";
|
||||
export * from "./no-results";
|
||||
export * from "./generator";
|
||||
export * from "./registration-check-email.icon";
|
||||
export * from "./search";
|
||||
export * from "./security";
|
||||
export * from "./send";
|
||||
export * from "./settings";
|
||||
export * from "./vault";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
import { svgIcon } from "../icon";
|
||||
|
||||
export const LockIcon = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" fill="none">
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
import { svgIcon } from "../icon";
|
||||
|
||||
export const RegistrationCheckEmailIcon = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" fill="none">
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
|
||||
export * from "./a11y";
|
||||
export * from "./anon-layout";
|
||||
export * from "./async-actions";
|
||||
export * from "./avatar";
|
||||
export * from "./badge-list";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./layout.component";
|
||||
export * from "./scroll-layout.directive";
|
||||
|
||||
@@ -1,43 +1,54 @@
|
||||
<div
|
||||
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
|
||||
>
|
||||
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
|
||||
<a
|
||||
bitLink
|
||||
class="tw-mx-6 focus-visible:before:!tw-ring-0"
|
||||
[fragment]="mainContentId"
|
||||
[routerLink]="[]"
|
||||
(click)="focusMainContent()"
|
||||
linkType="light"
|
||||
>{{ "skipToContent" | i18n }}</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
@let mainContentId = "main-content";
|
||||
<div class="tw-flex tw-w-full">
|
||||
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
|
||||
<main
|
||||
[id]="mainContentId"
|
||||
tabindex="-1"
|
||||
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<div class="tw-flex tw-w-full" cdkTrapFocus>
|
||||
<div
|
||||
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
|
||||
>
|
||||
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
|
||||
<a
|
||||
#skipLink
|
||||
bitLink
|
||||
class="tw-mx-6 focus-visible:before:!tw-ring-0"
|
||||
[fragment]="mainContentId"
|
||||
[routerLink]="[]"
|
||||
(click)="focusMainContent()"
|
||||
linkType="light"
|
||||
>{{ "skipToContent" | i18n }}</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
|
||||
<main
|
||||
#main
|
||||
[id]="mainContentId"
|
||||
tabindex="-1"
|
||||
bitScrollLayoutHost
|
||||
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div
|
||||
(click)="sideNavService.toggle()"
|
||||
class="tw-pointer-events-auto tw-size-full"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,61 @@
|
||||
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { Component, ElementRef, inject, viewChild } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
import { LinkModule } from "../link";
|
||||
import { SideNavService } from "../navigation/side-nav.service";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-layout",
|
||||
templateUrl: "layout.component.html",
|
||||
imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
LinkModule,
|
||||
RouterModule,
|
||||
PortalModule,
|
||||
A11yModule,
|
||||
CdkTrapFocus,
|
||||
ScrollLayoutHostDirective,
|
||||
],
|
||||
host: {
|
||||
"(document:keydown.tab)": "handleKeydown($event)",
|
||||
},
|
||||
hostDirectives: [DrawerHostDirective],
|
||||
})
|
||||
export class LayoutComponent {
|
||||
protected mainContentId = "main-content";
|
||||
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected drawerPortal = inject(DrawerHostDirective).portal;
|
||||
protected drawerPortal = inject(DrawerService).portal;
|
||||
|
||||
focusMainContent() {
|
||||
document.getElementById(this.mainContentId)?.focus();
|
||||
private mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
|
||||
protected focusMainContent() {
|
||||
this.mainContent().nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular CDK's focus trap utility is silly and will not respect focus order.
|
||||
* This is a workaround to explicitly focus the skip link when tab is first pressed, if no other item already has focus.
|
||||
*
|
||||
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
|
||||
**/
|
||||
private skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
|
||||
handleKeydown(ev: KeyboardEvent) {
|
||||
if (isNothingFocused()) {
|
||||
ev.preventDefault();
|
||||
this.skipLink().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isNothingFocused = (): boolean => {
|
||||
return [document.documentElement, document.body, null].includes(
|
||||
document.activeElement as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
98
libs/components/src/layout/scroll-layout.directive.ts
Normal file
98
libs/components/src/layout/scroll-layout.directive.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { CdkVirtualScrollable, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling";
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Injectable,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { filter, fromEvent, Observable, switchMap } from "rxjs";
|
||||
|
||||
/**
|
||||
* A service is needed because we can't inject a directive defined in the template of a parent component. The parent's template is initialized after projected content.
|
||||
**/
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class ScrollLayoutService {
|
||||
scrollableRef = signal<ElementRef<HTMLElement> | null>(null);
|
||||
scrollableRef$ = toObservable(this.scrollableRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the primary scrollable area of a layout component.
|
||||
*
|
||||
* Stores the element reference in a global service so it can be referenced by `ScrollLayoutDirective` even when it isn't a direct child of this directive.
|
||||
**/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayoutHost]",
|
||||
standalone: true,
|
||||
host: {
|
||||
class: "cdk-virtual-scrollable",
|
||||
},
|
||||
})
|
||||
export class ScrollLayoutHostDirective implements OnDestroy {
|
||||
private ref = inject(ElementRef);
|
||||
private service = inject(ScrollLayoutService);
|
||||
|
||||
constructor() {
|
||||
this.service.scrollableRef.set(this.ref as ElementRef<HTMLElement>);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.service.scrollableRef.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scroll viewport to the element marked with `ScrollLayoutHostDirective`.
|
||||
*
|
||||
* `ScrollLayoutHostDirective` is set on the primary scrollable area of a layout component (`bit-layout`, `popup-page`, etc).
|
||||
*
|
||||
* @see "Virtual Scrolling" in Storybook.
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayout]",
|
||||
standalone: true,
|
||||
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
|
||||
})
|
||||
export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit {
|
||||
private service = inject(ScrollLayoutService);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
effect(() => {
|
||||
const scrollableRef = this.service.scrollableRef();
|
||||
if (!scrollableRef) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("ScrollLayoutDirective can't find scroll host");
|
||||
return;
|
||||
}
|
||||
|
||||
this.elementRef = scrollableRef;
|
||||
});
|
||||
}
|
||||
|
||||
override elementScrolled(): Observable<Event> {
|
||||
return this.service.scrollableRef$.pipe(
|
||||
filter((ref) => ref !== null),
|
||||
switchMap((ref) => fromEvent(ref.nativeElement, "scroll")),
|
||||
);
|
||||
}
|
||||
|
||||
override getElementRef(): ElementRef<HTMLElement> {
|
||||
return this.service.scrollableRef()!;
|
||||
}
|
||||
|
||||
override measureBoundingClientRectWithScrollOffset(
|
||||
from: "left" | "top" | "right" | "bottom",
|
||||
): number {
|
||||
return (
|
||||
this.service.scrollableRef()!.nativeElement.getBoundingClientRect()[from] -
|
||||
this.measureScrollOffset(from)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,23 @@ import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { DialogModule, DialogService } from "../../../dialog";
|
||||
import { IconButtonModule } from "../../../icon-button";
|
||||
import { ScrollLayoutDirective } from "../../../layout";
|
||||
import { SectionComponent } from "../../../section";
|
||||
import { TableDataSource, TableModule } from "../../../table";
|
||||
|
||||
@Component({
|
||||
selector: "dialog-virtual-scroll-block",
|
||||
imports: [DialogModule, IconButtonModule, SectionComponent, TableModule, ScrollingModule],
|
||||
standalone: true,
|
||||
imports: [
|
||||
DialogModule,
|
||||
IconButtonModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
ScrollingModule,
|
||||
ScrollLayoutDirective,
|
||||
],
|
||||
template: /*html*/ `<bit-section>
|
||||
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="63.5">
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
|
||||
@@ -11,8 +11,69 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
|
||||
@Component({
|
||||
imports: [KitchenSinkSharedModule],
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" dialogSize="large">
|
||||
<span bitDialogContent> Dialog body text goes here. </span>
|
||||
<bit-dialog title="Dialog Title" dialogSize="small">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>What did foo say to bar?</bit-label>
|
||||
<input bitInput value="Baz" />
|
||||
</bit-form-field>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">OK</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
|
||||
@@ -88,72 +149,6 @@ class KitchenSinkDialog {
|
||||
</bit-section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
|
||||
<bit-drawer [(open)]="drawerOpen">
|
||||
<bit-drawer-header title="Foo ipsum"></bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>What did foo say to bar?</bit-label>
|
||||
<input bitInput value="Baz" />
|
||||
</bit-form-field>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
|
||||
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
</p>
|
||||
</bit-drawer-body>
|
||||
</bit-drawer>
|
||||
`,
|
||||
})
|
||||
export class KitchenSinkMainComponent {
|
||||
@@ -166,7 +161,7 @@ export class KitchenSinkMainComponent {
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.drawerOpen.set(true);
|
||||
this.dialogService.openDrawer(KitchenSinkDialog);
|
||||
}
|
||||
|
||||
navItems = [
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DialogService } from "../../dialog";
|
||||
import { LayoutComponent } from "../../layout";
|
||||
import { I18nMockService } from "../../utils/i18n-mock.service";
|
||||
import { positionFixedWrapperDecorator } from "../storybook-decorators";
|
||||
@@ -39,8 +38,20 @@ export default {
|
||||
KitchenSinkTable,
|
||||
KitchenSinkToggleList,
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
DialogService,
|
||||
provideNoopAnimations(),
|
||||
importProvidersFrom(
|
||||
RouterModule.forRoot(
|
||||
[
|
||||
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
|
||||
{ path: "bitwarden", component: KitchenSinkMainComponent },
|
||||
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
|
||||
],
|
||||
{ useHash: true },
|
||||
),
|
||||
),
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
@@ -58,21 +69,6 @@ export default {
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
importProvidersFrom(
|
||||
RouterModule.forRoot(
|
||||
[
|
||||
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
|
||||
{ path: "bitwarden", component: KitchenSinkMainComponent },
|
||||
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
|
||||
],
|
||||
{ useHash: true },
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
|
||||
60
libs/components/src/stories/virtual-scrolling.mdx
Normal file
60
libs/components/src/stories/virtual-scrolling.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Meta } from "@storybook/addon-docs";
|
||||
|
||||
<Meta title="Documentation/Virtual Scrolling" />
|
||||
|
||||
# Virtual Scrolling
|
||||
|
||||
Virtual scrolling is a technique that improves the rendering performance of very large lists by only
|
||||
rendering whatever is currently visible within the viewport. We build on top of
|
||||
[Angular CDK's `ScrollingModule`](https://material.angular.dev/cdk/scrolling/overview).
|
||||
|
||||
## Scrolling the entire layout
|
||||
|
||||
Often, a design calls for the scroll container to envelop the entire page. To support this,
|
||||
AngularCDK provides a `scrollWindow` directive that sets the window to be virtual scroll viewport.
|
||||
We export a similar directive, `bitScrollLayout`, that integrates with `bit-layout` and `popup-page`
|
||||
and should be used instead of `scrollWindow`.
|
||||
|
||||
```html
|
||||
<!-- Descendant of bit-layout -->
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</cdk-virtual-scroll-viewport>
|
||||
```
|
||||
|
||||
### Known footgun
|
||||
|
||||
Due to the initialization order of Angular components and their templates, `bitScrollLayout` will
|
||||
error if it is used _in the same template_ as the layout component:
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</bit-layout>
|
||||
```
|
||||
|
||||
In this particular composition, the child content gets constructed before the template of
|
||||
`bit-layout` and thus has no scroll container to reference. Workarounds include:
|
||||
|
||||
1. Wrap the child in another component. (This tends to happen by default when the layout is
|
||||
integrated with a `router-outlet`.)
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
<component-that-contains-bitScrollLayout></component-that-contains-bitScrollLayout>
|
||||
</bit-layout>
|
||||
```
|
||||
|
||||
2. Use a `defer` block.
|
||||
|
||||
```html
|
||||
<bit-layout>
|
||||
@defer (on immediate) {
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout>
|
||||
<!-- virtual scroll implementation here -->
|
||||
</div>
|
||||
}
|
||||
</bit-layout>
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<cdk-virtual-scroll-viewport
|
||||
scrollWindow
|
||||
bitScrollLayout
|
||||
[itemSize]="rowSize"
|
||||
[ngStyle]="{ paddingBottom: headerHeight + 'px' }"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CdkVirtualScrollViewport,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
CdkVirtualScrollableWindow,
|
||||
} from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
TrackByFunction,
|
||||
} from "@angular/core";
|
||||
|
||||
import { ScrollLayoutDirective } from "../layout";
|
||||
|
||||
import { RowDirective } from "./row.directive";
|
||||
import { TableComponent } from "./table.component";
|
||||
|
||||
@@ -52,10 +53,10 @@ export class BitRowDef {
|
||||
imports: [
|
||||
CommonModule,
|
||||
CdkVirtualScrollViewport,
|
||||
CdkVirtualScrollableWindow,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
RowDirective,
|
||||
ScrollLayoutDirective,
|
||||
],
|
||||
})
|
||||
export class TableScrollComponent
|
||||
|
||||
@@ -142,7 +142,7 @@ dataSource.filter = (data) => data.orgType === "family";
|
||||
|
||||
Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`.
|
||||
It works by converting each entry into a string of it's properties. The provided string is then
|
||||
compared against the filter value using a simple `indexOf` check. For convienence, you can also just
|
||||
compared against the filter value using a simple `indexOf` check. For convenience, you can also just
|
||||
pass a string directly.
|
||||
|
||||
```ts
|
||||
@@ -153,7 +153,7 @@ dataSource.filter = "search value";
|
||||
|
||||
### Virtual Scrolling
|
||||
|
||||
It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount
|
||||
It's heavily advised to use virtual scrolling if you expect the table to have any significant amount
|
||||
of data. This is done by using the `bit-table-scroll` component instead of the `bit-table`
|
||||
component. This component behaves slightly different from the `bit-table` component. Instead of
|
||||
using the `*ngFor` directive to render the rows, you provide a `bitRowDef` template that will be
|
||||
@@ -178,6 +178,14 @@ height and align vertically.
|
||||
</bit-table-scroll>
|
||||
```
|
||||
|
||||
#### Deprecated approach
|
||||
|
||||
Before `bit-table-scroll` was introduced, virtual scroll in tables was implemented manually via
|
||||
constructs from Angular CDK. This included wrapping the table with a `cdk-virtual-scroll-viewport`
|
||||
and targeting with `bit-layout`'s scroll container with the `bitScrollLayout` directive.
|
||||
|
||||
This pattern is deprecated in favor of `bit-table-scroll`.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Always include a row or column header with your table; this allows assistive technology to better
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { countries } from "../form/countries";
|
||||
import { LayoutComponent } from "../layout";
|
||||
import { mockLayoutI18n } from "../layout/mocks";
|
||||
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
|
||||
import { I18nMockService } from "../utils";
|
||||
|
||||
import { TableDataSource } from "./table-data-source";
|
||||
import { TableModule } from "./table.module";
|
||||
@@ -8,8 +15,17 @@ import { TableModule } from "./table.module";
|
||||
export default {
|
||||
title: "Component Library/Table",
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
imports: [TableModule],
|
||||
imports: [TableModule, LayoutComponent, RouterTestingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService(mockLayoutI18n);
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
@@ -116,18 +132,20 @@ export const Scrollable: Story = {
|
||||
trackBy: (index: number, item: any) => item.id,
|
||||
},
|
||||
template: `
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="id" default>Id</th>
|
||||
<th bitCell bitSortable="name">Name</th>
|
||||
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.id }}</td>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.other }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
<bit-layout>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="id" default>Id</th>
|
||||
<th bitCell bitSortable="name">Name</th>
|
||||
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.id }}</td>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.other }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -144,17 +162,19 @@ export const Filterable: Story = {
|
||||
sortFn: (a: any, b: any) => a.id - b.id,
|
||||
},
|
||||
template: `
|
||||
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="name" default>Name</th>
|
||||
<th bitCell bitSortable="value" width="120px">Value</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.value }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
<bit-layout>
|
||||
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="name" default>Name</th>
|
||||
<th bitCell bitSortable="value" width="120px">Value</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell>{{ row.value }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
41
libs/components/src/utils/has-scrolled-from.ts
Normal file
41
libs/components/src/utils/has-scrolled-from.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { Signal, inject, signal } from "@angular/core";
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map, startWith, switchMap } from "rxjs";
|
||||
|
||||
export type ScrollState = {
|
||||
/** `true` when the scrollbar is not at the top-most position */
|
||||
top: boolean;
|
||||
|
||||
/** `true` when the scrollbar is not at the bottom-most position */
|
||||
bottom: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a `CdkScrollable` instance has been scrolled
|
||||
* @param scrollable The instance to check, defaults to the one provided by the current injector
|
||||
* @returns {Signal<ScrollState>}
|
||||
*/
|
||||
export const hasScrolledFrom = (scrollable?: Signal<CdkScrollable>): Signal<ScrollState> => {
|
||||
const _scrollable = scrollable ?? signal(inject(CdkScrollable));
|
||||
const scrollable$ = toObservable(_scrollable);
|
||||
|
||||
const scrollState$ = scrollable$.pipe(
|
||||
switchMap((_scrollable) =>
|
||||
_scrollable.elementScrolled().pipe(
|
||||
startWith(null),
|
||||
map(() => ({
|
||||
top: _scrollable.measureScrollOffset("top") > 0,
|
||||
bottom: _scrollable.measureScrollOffset("bottom") > 0,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return toSignal(scrollState$, {
|
||||
initialValue: {
|
||||
top: false,
|
||||
bottom: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
|
||||
import { LogoutService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
@@ -42,6 +41,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
AnonLayoutWrapperDataService,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
|
||||
@@ -24,10 +24,6 @@ export * as VaultIcons from "./icons";
|
||||
|
||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
export {
|
||||
RestrictedItemTypesService,
|
||||
RestrictedCipherType,
|
||||
} from "./services/restricted-item-types.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
|
||||
Reference in New Issue
Block a user