1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +00:00

Merge remote-tracking branch 'origin' into auth/pm-18720/change-password-component-non-dialog-v3

This commit is contained in:
Patrick Pimentel
2025-06-10 16:51:21 -06:00
521 changed files with 7401 additions and 6359 deletions

View File

@@ -11,7 +11,6 @@ import { ButtonModule } from "@bitwarden/components";
*/
@Component({
selector: "app-authentication-timeout",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
template: `
<p class="tw-text-center">

View File

@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
TextDragDirective,
CopyClickDirective,
A11yTitleDirective,
AutofocusDirective,
],
declarations: [
A11yInvalidDirective,
ApiActionDirective,
AutofocusDirective,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,

View File

@@ -0,0 +1,36 @@
import { mock } from "jest-mock-extended";
import { Subject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DocumentLangSetter } from "./document-lang.setter";
describe("DocumentLangSetter", () => {
const document = mock<Document>();
const i18nService = mock<I18nService>();
const sut = new DocumentLangSetter(document, i18nService);
describe("start", () => {
it("reacts to locale changes while start called with a non-closed subscription", async () => {
const localeSubject = new Subject<string>();
i18nService.locale$ = localeSubject;
localeSubject.next("en");
expect(document.documentElement.lang).toBeFalsy();
const sub = sut.start();
localeSubject.next("es");
expect(document.documentElement.lang).toBe("es");
sub.unsubscribe();
localeSubject.next("ar");
expect(document.documentElement.lang).toBe("es");
});
});
});

View File

@@ -0,0 +1,26 @@
import { Subscription } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
/**
* A service for managing the setting of the `lang="<locale>" attribute on the
* main document for the application.
*/
export class DocumentLangSetter {
constructor(
private readonly document: Document,
private readonly i18nService: I18nService,
) {}
/**
* Starts listening to an upstream source for the best locale for the user
* and applies it to the application document.
* @returns A subscription that can be unsubscribed if you wish to stop
* applying lang attribute updates to the application document.
*/
start(): Subscription {
return this.i18nService.locale$.subscribe((locale) => {
this.document.documentElement.lang = locale;
});
}
}

View File

@@ -0,0 +1 @@
export { DocumentLangSetter } from "./document-lang.setter";

View File

@@ -21,6 +21,7 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
export { SafeInjectionToken } from "@bitwarden/ui-common";
export const WINDOW = new SafeInjectionToken<Window>("WINDOW");
export const DOCUMENT = new SafeInjectionToken<Document>("DOCUMENT");
export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE");

View File

@@ -337,6 +337,7 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
@@ -349,6 +350,7 @@ import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
DOCUMENT,
ENV_ADDITIONAL_REGIONS,
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
@@ -378,6 +380,7 @@ const safeProviders: SafeProvider[] = [
safeProvider(ModalService),
safeProvider(PasswordRepromptService),
safeProvider({ provide: WINDOW, useValue: window }),
safeProvider({ provide: DOCUMENT, useValue: document }),
safeProvider({
provide: LOCALE_ID as SafeInjectionToken<string>,
useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale,
@@ -1460,12 +1463,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: CipherAuthorizationService,
useClass: DefaultCipherAuthorizationService,
deps: [
CollectionService,
OrganizationServiceAbstraction,
AccountServiceAbstraction,
ConfigService,
],
deps: [CollectionService, OrganizationServiceAbstraction, AccountServiceAbstraction],
}),
safeProvider({
provide: AuthRequestApiService,
@@ -1512,7 +1510,6 @@ const safeProviders: SafeProvider[] = [
StateProvider,
ApiServiceAbstraction,
OrganizationServiceAbstraction,
ConfigService,
AuthServiceAbstraction,
NotificationsService,
MessageListener,
@@ -1544,6 +1541,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: DocumentLangSetter,
useClass: DocumentLangSetter,
deps: [DOCUMENT, I18nServiceAbstraction],
}),
safeProvider({
provide: CipherEncryptionService,
useClass: DefaultCipherEncryptionService,

View File

@@ -25,7 +25,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { UserId } from "@bitwarden/common/types/guid";
import {
CipherService,
EncryptionContext,
@@ -348,7 +348,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(
this.cipher,
[this.collectionId as CollectionId],
this.isAdminConsoleAction,
);

View File

@@ -40,7 +40,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@@ -521,9 +521,7 @@ export class ViewComponent implements OnDestroy, OnInit {
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {

View File

@@ -1,42 +0,0 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@Injectable({ providedIn: "root" })
export class DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Failed to load profile date:");
// Default to today to ensure the nudge is shown
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -30,10 +30,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const filteredCiphers = ciphers?.filter((cipher) => {
return cipher.deletedDate == null;
});
const vaultHasContents = !(filteredCiphers == null || filteredCiphers.length === 0);
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
if (orgs == null || orgs.length === 0) {
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
? of(nudgeStatus)
@@ -47,18 +44,22 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// Do not show nudge when
// user has previously dismissed nudge
// OR
// user belongs to an organization and cannot create collections || manage collections
if (
nudgeStatus.hasBadgeDismissed ||
nudgeStatus.hasSpotlightDismissed ||
hasManageCollections ||
canCreateCollections
) {
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) {
return of(nudgeStatus);
}
// When the user belongs to an organization and cannot create collections or manage collections,
// hide the nudge and spotlight
if (!hasManageCollections && !canCreateCollections) {
return of({
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
});
}
// Otherwise, return the nudge status based on the vault contents
return of({
hasSpotlightDismissed: vaultHasContents,
hasBadgeDismissed: vaultHasContents,

View File

@@ -1,6 +1,6 @@
export * from "./autofill-nudge.service";
export * from "./account-security-nudge.service";
export * from "./has-items-nudge.service";
export * from "./download-bitwarden-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";
export * from "./new-account-nudge.service";

View File

@@ -12,16 +12,16 @@ import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Custom Nudge Service to use for the Autofill Nudge in the Vault
* Custom Nudge Service to check if account is older than 30 days
*/
@Injectable({
providedIn: "root",
})
export class AutofillNudgeService extends DefaultSingleNudgeService {
export class NewAccountNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(_: NudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Error getting profile creation date");
@@ -32,7 +32,7 @@ export class AutofillNudgeService extends DefaultSingleNudgeService {
return combineLatest([
profileDate$,
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {

View File

@@ -0,0 +1,74 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, of, switchMap } from "rxjs";
// 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 { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service for the vault settings import badge.
*/
@Injectable({
providedIn: "root",
})
export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;
const { hasBadgeDismissed, hasSpotlightDismissed } = nudgeStatus;
// When the user has no organizations, return the nudge status directly
if ((orgs?.length ?? 0) === 0) {
return hasBadgeDismissed || hasSpotlightDismissed
? of(nudgeStatus)
: of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (hasBadgeDismissed || hasSpotlightDismissed) {
return of(nudgeStatus);
}
// When the user belongs to an organization and cannot create collections or manage collections,
// hide the nudge and spotlight
if (!hasManageCollections && !canCreateCollections) {
return of({
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
});
}
// Otherwise, return the nudge status based on the vault contents
return of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}),
);
}
}

View File

@@ -19,7 +19,8 @@ import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/c
import {
HasItemsNudgeService,
EmptyVaultNudgeService,
DownloadBitwardenNudgeService,
NewAccountNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { NudgesService, NudgeType } from "./nudges.service";
@@ -33,7 +34,7 @@ describe("Vault Nudges Service", () => {
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const nudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
const nudgeServices = [EmptyVaultNudgeService, NewAccountNudgeService];
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
@@ -57,13 +58,17 @@ describe("Vault Nudges Service", () => {
useValue: mock<HasItemsNudgeService>(),
},
{
provide: DownloadBitwardenNudgeService,
useValue: mock<DownloadBitwardenNudgeService>(),
provide: NewAccountNudgeService,
useValue: mock<NewAccountNudgeService>(),
},
{
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
{
provide: VaultSettingsImportNudgeService,
useValue: mock<VaultSettingsImportNudgeService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),

View File

@@ -5,14 +5,15 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
NewAccountNudgeService,
HasItemsNudgeService,
EmptyVaultNudgeService,
AutofillNudgeService,
DownloadBitwardenNudgeService,
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
@@ -24,24 +25,23 @@ export type NudgeStatus = {
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum NudgeType {
/** Nudge to show when user has no items in their vault
* Add future nudges here
*/
EmptyVaultNudge = "empty-vault-nudge",
HasVaultItems = "has-vault-items",
AutofillNudge = "autofill-nudge",
AccountSecurity = "account-security",
DownloadBitwarden = "download-bitwarden",
NewLoginItemStatus = "new-login-item-status",
NewCardItemStatus = "new-card-item-status",
NewIdentityItemStatus = "new-identity-item-status",
NewNoteItemStatus = "new-note-item-status",
NewSshItemStatus = "new-ssh-item-status",
GeneratorNudgeStatus = "generator-nudge-status",
}
export const NudgeType = {
/** Nudge to show when user has no items in their vault */
EmptyVaultNudge: "empty-vault-nudge",
VaultSettingsImportNudge: "vault-settings-import-nudge",
HasVaultItems: "has-vault-items",
AutofillNudge: "autofill-nudge",
AccountSecurity: "account-security",
DownloadBitwarden: "download-bitwarden",
NewLoginItemStatus: "new-login-item-status",
NewCardItemStatus: "new-card-item-status",
NewIdentityItemStatus: "new-identity-item-status",
NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status",
} as const;
export type NudgeType = UnionOfValues<typeof NudgeType>;
export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<NudgeType, NudgeStatus>>
@@ -55,6 +55,7 @@ export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
})
export class NudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
private newAcctNudgeService = inject(NewAccountNudgeService);
/**
* Custom nudge services to use for specific nudge types
@@ -64,9 +65,11 @@ export class NudgesService {
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
[NudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[NudgeType.VaultSettingsImportNudge]: inject(VaultSettingsImportNudgeService),
[NudgeType.AccountSecurity]: inject(AccountSecurityNudgeService),
[NudgeType.AutofillNudge]: inject(AutofillNudgeService),
[NudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService),
[NudgeType.AutofillNudge]: this.newAcctNudgeService,
[NudgeType.DownloadBitwarden]: this.newAcctNudgeService,
[NudgeType.GeneratorNudgeStatus]: this.newAcctNudgeService,
[NudgeType.NewLoginItemStatus]: this.newItemNudgeService,
[NudgeType.NewCardItemStatus]: this.newItemNudgeService,
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,

View File

@@ -44,7 +44,6 @@ export interface AnonLayoutWrapperData {
}
@Component({
standalone: true,
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})

View File

@@ -19,7 +19,6 @@ import { TypographyModule } from "../../../../components/src/typography";
import { BitwardenLogo, BitwardenShield } from "../icons";
@Component({
standalone: true,
selector: "auth-anon-layout",
templateUrl: "./anon-layout.component.html",
imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule],

View File

@@ -35,7 +35,6 @@ import { ChangePasswordService } from "./change-password.service.abstraction";
* end up at a change password without having one before.
*/
@Component({
standalone: true,
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],

View File

@@ -11,7 +11,6 @@ export type FingerprintDialogData = {
@Component({
templateUrl: "fingerprint-dialog.component.html",
standalone: true,
imports: [JslibModule, ButtonModule, DialogModule],
})
export class FingerprintDialogComponent {

View File

@@ -83,7 +83,6 @@ interface InputPasswordForm {
}
@Component({
standalone: true,
selector: "auth-input-password",
templateUrl: "./input-password.component.html",
imports: [

View File

@@ -40,7 +40,6 @@ export interface LoginApprovalDialogParams {
@Component({
selector: "login-approval",
templateUrl: "login-approval.component.html",
standalone: true,
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
@@ -101,6 +100,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
this.updateTimeText();
}, RequestTimeUpdate);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
this.loading = false;

View File

@@ -51,7 +51,6 @@ enum State {
}
@Component({
standalone: true,
templateUrl: "./login-decryption-options.component.html",
imports: [
AsyncActionsModule,

View File

@@ -57,7 +57,6 @@ const matchOptions: IsActiveMatchOptions = {
};
@Component({
standalone: true,
templateUrl: "./login-via-auth-request.component.html",
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
providers: [{ provide: LoginViaAuthRequestCacheService }],

View File

@@ -9,7 +9,6 @@ import { DefaultServerSettingsService } from "@bitwarden/common/platform/service
import { LinkModule } from "@bitwarden/components";
@Component({
standalone: true,
imports: [CommonModule, JslibModule, LinkModule, RouterModule],
template: `
<div class="tw-text-center" *ngIf="!(isUserRegistrationDisabled$ | async)">

View File

@@ -58,7 +58,6 @@ export enum LoginUiState {
}
@Component({
standalone: true,
templateUrl: "./login.component.html",
imports: [
AsyncActionsModule,

View File

@@ -25,7 +25,6 @@ import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login
* Component for verifying a new device via a one-time password (OTP).
*/
@Component({
standalone: true,
selector: "app-new-device-verification",
templateUrl: "./new-device-verification.component.html",
imports: [
@@ -138,6 +137,8 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginSuccessHandlerService.run(authResult.userId);
// If verification succeeds, navigate to vault

View File

@@ -13,7 +13,6 @@ import { CalloutModule } from "@bitwarden/components";
@Component({
selector: "auth-password-callout",
templateUrl: "password-callout.component.html",
standalone: true,
imports: [CommonModule, JslibModule, CalloutModule],
})
export class PasswordCalloutComponent {

View File

@@ -23,7 +23,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
templateUrl: "./password-hint.component.html",
imports: [
AsyncActionsModule,

View File

@@ -26,7 +26,6 @@ import { SelfHostedEnvConfigDialogComponent } from "../../self-hosted-env-config
* Outputs the selected region to the parent component so it can respond as necessary.
*/
@Component({
standalone: true,
selector: "auth-registration-env-selector",
templateUrl: "registration-env-selector.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],

View File

@@ -33,7 +33,6 @@ import { PasswordInputResult } from "../../input-password/password-input-result"
import { RegistrationFinishService } from "./registration-finish.service";
@Component({
standalone: true,
selector: "auth-registration-finish",
templateUrl: "./registration-finish.component.html",
imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent],

View File

@@ -21,7 +21,6 @@ export interface RegistrationLinkExpiredComponentData {
}
@Component({
standalone: true,
selector: "auth-registration-link-expired",
templateUrl: "./registration-link-expired.component.html",
imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule],

View File

@@ -19,7 +19,6 @@ export interface RegistrationStartSecondaryComponentData {
}
@Component({
standalone: true,
selector: "auth-registration-start-secondary",
templateUrl: "./registration-start-secondary.component.html",
imports: [CommonModule, JslibModule, RouterModule, LinkModule],

View File

@@ -42,7 +42,6 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record<Region, boolean> = {
};
@Component({
standalone: true,
selector: "auth-registration-start",
templateUrl: "./registration-start.component.html",
imports: [

View File

@@ -55,7 +55,6 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
* Dialog for configuring self-hosted environment settings.
*/
@Component({
standalone: true,
selector: "self-hosted-env-config-dialog",
templateUrl: "self-hosted-env-config-dialog.component.html",
imports: [

View File

@@ -30,7 +30,6 @@ import {
} from "./set-password-jit.service.abstraction";
@Component({
standalone: true,
selector: "auth-set-password-jit",
templateUrl: "set-password-jit.component.html",
imports: [CommonModule, InputPasswordComponent, JslibModule],

View File

@@ -62,7 +62,6 @@ interface QueryParams {
* This component handles the SSO flow.
*/
@Component({
standalone: true,
templateUrl: "sso.component.html",
imports: [
AsyncActionsModule,

View File

@@ -15,7 +15,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-two-factor-auth-authenticator",
templateUrl: "two-factor-auth-authenticator.component.html",
imports: [

View File

@@ -26,7 +26,6 @@ import {
} from "./two-factor-auth-duo-component.service";
@Component({
standalone: true,
selector: "app-two-factor-auth-duo",
template: "",
imports: [

View File

@@ -28,7 +28,6 @@ import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
@Component({
standalone: true,
selector: "app-two-factor-auth-email",
templateUrl: "two-factor-auth-email.component.html",
imports: [

View File

@@ -33,7 +33,6 @@ export interface WebAuthnResult {
}
@Component({
standalone: true,
selector: "app-two-factor-auth-webauthn",
templateUrl: "two-factor-auth-webauthn.component.html",
imports: [

View File

@@ -15,7 +15,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-two-factor-auth-yubikey",
templateUrl: "two-factor-auth-yubikey.component.html",
imports: [

View File

@@ -77,7 +77,6 @@ import {
} from "./two-factor-options.component";
@Component({
standalone: true,
selector: "app-two-factor-auth",
templateUrl: "two-factor-auth.component.html",
imports: [
@@ -266,6 +265,8 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private listenForAuthnSessionTimeout() {
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed(this.destroyRef))
// TODO: Fix this!
// eslint-disable-next-line rxjs/no-async-subscribe
.subscribe(async (expired) => {
if (!expired) {
return;

View File

@@ -32,7 +32,6 @@ export type TwoFactorOptionsDialogResult = {
};
@Component({
standalone: true,
selector: "app-two-factor-options",
templateUrl: "two-factor-options.component.html",
imports: [

View File

@@ -32,7 +32,6 @@ import { UserVerificationFormInputComponent } from "./user-verification-form-inp
@Component({
templateUrl: "user-verification-dialog.component.html",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,

View File

@@ -56,7 +56,6 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt
transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]),
]),
],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,

View File

@@ -47,7 +47,6 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
@Component({
selector: "auth-vault-timeout-input",
templateUrl: "vault-timeout-input.component.html",
standalone: true,
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
providers: [
{

View File

@@ -105,23 +105,6 @@ describe("AuthRequestService", () => {
);
});
it("should use the master key and hash if they exist", async () => {
masterPasswordService.masterKeySubject.next(
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
);
masterPasswordService.masterKeyHashSubject.next("MASTER_KEY_HASH");
await sut.approveOrDenyAuthRequest(
true,
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
new SymmetricCryptoKey(new Uint8Array(32)),
expect.anything(),
);
});
it("should use the user key if the master key and hash do not exist", async () => {
keyService.getUserKey.mockResolvedValueOnce(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
@@ -246,45 +229,6 @@ describe("AuthRequestService", () => {
});
});
describe("decryptAuthReqPubKeyEncryptedMasterKeyAndHash", () => {
it("returns a decrypted master key and hash when given a valid public key encrypted master key, public key encrypted master key hash, and an auth req private key", async () => {
// Arrange
const mockPubKeyEncryptedMasterKey = "pubKeyEncryptedMasterKey";
const mockPubKeyEncryptedMasterKeyHash = "pubKeyEncryptedMasterKeyHash";
const mockDecryptedMasterKeyBytes = new Uint8Array(64);
const mockDecryptedMasterKey = new SymmetricCryptoKey(
mockDecryptedMasterKeyBytes,
) as MasterKey;
const mockDecryptedMasterKeyHashBytes = new Uint8Array(64);
const mockDecryptedMasterKeyHash = Utils.fromBufferToUtf8(mockDecryptedMasterKeyHashBytes);
encryptService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes);
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
new SymmetricCryptoKey(mockDecryptedMasterKeyBytes),
);
// Act
const result = await sut.decryptPubKeyEncryptedMasterKeyAndHash(
mockPubKeyEncryptedMasterKey,
mockPubKeyEncryptedMasterKeyHash,
mockPrivateKey,
);
// Assert
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(mockPubKeyEncryptedMasterKey),
mockPrivateKey,
);
expect(encryptService.rsaDecrypt).toHaveBeenCalledWith(
new EncString(mockPubKeyEncryptedMasterKeyHash),
mockPrivateKey,
);
expect(result.masterKey).toEqual(mockDecryptedMasterKey);
expect(result.masterKeyHash).toEqual(mockDecryptedMasterKeyHash);
});
});
describe("getFingerprintPhrase", () => {
it("returns the same fingerprint regardless of email casing", () => {
const email = "test@email.com";

View File

@@ -103,32 +103,12 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
}
const pubKey = Utils.fromB64ToArray(authRequest.publicKey);
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
const masterKeyHash = await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId));
let encryptedMasterKeyHash;
let keyToEncrypt;
if (masterKey && masterKeyHash) {
// Only encrypt the master password hash if masterKey exists as
// we won't have a masterKeyHash without a masterKey
encryptedMasterKeyHash = await this.encryptService.rsaEncrypt(
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
keyToEncrypt = masterKey;
} else {
keyToEncrypt = await this.keyService.getUserKey();
}
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(
keyToEncrypt as SymmetricCryptoKey,
pubKey,
);
const keyToEncrypt = await this.keyService.getUserKey();
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(keyToEncrypt, pubKey);
const response = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
encryptedMasterKeyHash?.encryptedString,
undefined,
await this.appIdService.getAppId(),
approve,
);
@@ -173,10 +153,12 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedUserKey: string,
privateKey: Uint8Array,
): Promise<UserKey> {
return (await this.encryptService.decapsulateKeyUnsigned(
const decryptedUserKey = await this.encryptService.decapsulateKeyUnsigned(
new EncString(pubKeyEncryptedUserKey),
privateKey,
)) as UserKey;
);
return decryptedUserKey as UserKey;
}
async decryptPubKeyEncryptedMasterKeyAndHash(
@@ -184,15 +166,17 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedMasterKeyHash: string,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
const masterKey = (await this.encryptService.decapsulateKeyUnsigned(
const decryptedMasterKeyArrayBuffer = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedMasterKey),
privateKey,
)) as MasterKey;
);
const decryptedMasterKeyHashArrayBuffer = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedMasterKeyHash),
privateKey,
);
const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey;
const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer);
return {

View File

@@ -208,7 +208,7 @@ export abstract class ApiService {
deleteManyCiphersAdmin: (request: CipherBulkDeleteRequest) => Promise<any>;
putMoveCiphers: (request: CipherBulkMoveRequest) => Promise<any>;
putShareCipher: (id: string, request: CipherShareRequest) => Promise<CipherResponse>;
putShareCiphers: (request: CipherBulkShareRequest) => Promise<CipherResponse[]>;
putShareCiphers: (request: CipherBulkShareRequest) => Promise<ListResponse<CipherResponse>>;
putCipherCollections: (
id: string,
request: CipherCollectionsRequest,

View File

@@ -16,4 +16,5 @@ export enum PolicyType {
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
RestrictedItemTypesPolicy = 15, // Restricts item types that can be created within an organization
}

View File

@@ -11,7 +11,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum FeatureFlag {
/* Admin Console Team */
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript",
@@ -38,7 +37,6 @@ export enum FeatureFlag {
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
UserKeyRotationV2 = "userkey-rotation-v2",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UseSDKForDecryption = "use-sdk-for-decryption",
PM17987_BlockType0 = "pm-17987-block-type-0",
@@ -51,7 +49,6 @@ export enum FeatureFlag {
/* Vault */
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
SecurityTasks = "security-tasks",
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
@@ -77,7 +74,6 @@ const FALSE = false as boolean;
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.LimitItemDeletion]: FALSE,
[FeatureFlag.SeparateCustomRolePermissions]: FALSE,
[FeatureFlag.OptimizeNestedTraverseTypescript]: FALSE,
@@ -98,7 +94,6 @@ export const DefaultFeatureFlagValue = {
/* Vault */
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
@@ -118,7 +113,6 @@ export const DefaultFeatureFlagValue = {
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UseSDKForDecryption]: FALSE,
[FeatureFlag.PM17987_BlockType0]: FALSE,

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { OtherDeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { EncString } from "../../../platform/models/domain/enc-string";
@@ -61,5 +61,5 @@ export abstract class DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<DeviceKeysUpdateRequest[]>;
) => Promise<OtherDeviceKeysUpdateRequest[]>;
}

View File

@@ -200,7 +200,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<DeviceKeysUpdateRequest[]> {
): Promise<OtherDeviceKeysUpdateRequest[]> {
if (!userId) {
throw new Error("UserId is required. Cannot get rotated data.");
}

View File

@@ -92,7 +92,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
clientSecret,
]);
await this.keyService.refreshAdditionalKeys();
await this.keyService.refreshAdditionalKeys(userId);
}
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {

View File

@@ -417,16 +417,12 @@ describe("VaultTimeoutService", () => {
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call locked callback if no user passed into lock", async () => {
it("should call locked callback with the locking user if no userID is passed in.", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(lockedCallback).toHaveBeenCalledWith(undefined);
expect(lockedCallback).toHaveBeenCalledWith("user1");
});
it("should call state event runner with user passed into lock", async () => {

View File

@@ -49,7 +49,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private lockedCallback: (userId: UserId) => Promise<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
userId?: string,
@@ -166,7 +166,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) {
await this.lockedCallback(userId);
await this.lockedCallback(lockingUserId);
}
}

View File

@@ -260,7 +260,7 @@ export class Utils {
});
}
static guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
static guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
static isGuid(id: string) {
return RegExp(Utils.guidRegex, "i").test(id);

View File

@@ -108,14 +108,19 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
return this.webPushConnectionService.supportStatus$(userId);
}),
supportSwitch({
supported: (service) =>
service.notifications$.pipe(
supported: (service) => {
this.logService.info("Using WebPush for notifications");
return service.notifications$.pipe(
catchError((err: unknown) => {
this.logService.warning("Issue with web push, falling back to SignalR", err);
return this.connectSignalR$(userId, notificationsUrl);
}),
),
notSupported: () => this.connectSignalR$(userId, notificationsUrl),
);
},
notSupported: () => {
this.logService.info("Using SignalR for notifications");
return this.connectSignalR$(userId, notificationsUrl);
},
}),
);
}

View File

@@ -7,12 +7,14 @@
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/
import { Utils } from "../../../platform/misc/utils";
/** Private array used for optimization */
const byteToHex = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).substring(1));
/** Convert standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID to raw 16 byte array. */
export function guidToRawFormat(guid: string) {
if (!isValidGuid(guid)) {
if (!Utils.isGuid(guid)) {
throw TypeError("GUID parameter is invalid");
}
@@ -81,15 +83,13 @@ export function guidToStandardFormat(bufferSource: BufferSource) {
).toLowerCase();
// Consistency check for valid UUID. If this throws, it's likely due to one
// or more input array values not mapping to a hex octet (leading to "undefined" in the uuid)
if (!isValidGuid(guid)) {
// of the following:
// - One or more input array values don't map to a hex octet (leading to
// "undefined" in the uuid)
// - Invalid input values for the RFC `version` or `variant` fields
if (!Utils.isGuid(guid)) {
throw TypeError("Converted GUID is invalid");
}
return guid;
}
// Perform format validation, without enforcing any variant restrictions as Utils.isGuid does
function isValidGuid(guid: string): boolean {
return RegExp(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/, "i").test(guid);
}

View File

@@ -532,8 +532,9 @@ export class ApiService implements ApiServiceAbstraction {
return new CipherResponse(r);
}
async putShareCiphers(request: CipherBulkShareRequest): Promise<CipherResponse[]> {
return await this.send("PUT", "/ciphers/share", request, true, true);
async putShareCiphers(request: CipherBulkShareRequest): Promise<ListResponse<CipherResponse>> {
const r = await this.send("PUT", "/ciphers/share", request, true, true);
return new ListResponse<CipherResponse>(r, CipherResponse);
}
async putCipherCollections(

View File

@@ -1,6 +1,8 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum CipherRepromptType {
None = 0,
Password = 1,
}
import { UnionOfValues } from "../types/union-of-values";
export const CipherRepromptType = {
None: 0,
Password: 1,
} as const;
export type CipherRepromptType = UnionOfValues<typeof CipherRepromptType>;

View File

@@ -0,0 +1,66 @@
import {
CipherType,
cipherTypeNames,
isCipherType,
toCipherType,
toCipherTypeName,
} from "./cipher-type";
describe("CipherType", () => {
describe("toCipherTypeName", () => {
it("should map CipherType correctly", () => {
// identity test as the value is calculated
expect(cipherTypeNames).toEqual({
1: "Login",
2: "SecureNote",
3: "Card",
4: "Identity",
5: "SshKey",
});
});
});
describe("toCipherTypeName", () => {
it("returns the associated name for the cipher type", () => {
expect(toCipherTypeName(1)).toBe("Login");
expect(toCipherTypeName(2)).toBe("SecureNote");
expect(toCipherTypeName(3)).toBe("Card");
expect(toCipherTypeName(4)).toBe("Identity");
expect(toCipherTypeName(5)).toBe("SshKey");
});
it("returns undefined for an invalid cipher type", () => {
expect(toCipherTypeName(999 as any)).toBeUndefined();
expect(toCipherTypeName("" as any)).toBeUndefined();
});
});
describe("isCipherType", () => {
it("returns true for valid CipherType values", () => {
[1, 2, 3, 4, 5].forEach((value) => {
expect(isCipherType(value)).toBe(true);
});
});
it("returns false for invalid CipherType values", () => {
expect(isCipherType(999 as any)).toBe(false);
expect(isCipherType("Login" as any)).toBe(false);
expect(isCipherType(null)).toBe(false);
expect(isCipherType(undefined)).toBe(false);
});
});
describe("toCipherType", () => {
it("converts valid values to CipherType", () => {
expect(toCipherType("1")).toBe(CipherType.Login);
expect(toCipherType("02")).toBe(CipherType.SecureNote);
});
it("returns null for invalid values", () => {
expect(toCipherType(999 as any)).toBeUndefined();
expect(toCipherType("Login" as any)).toBeUndefined();
expect(toCipherType(null)).toBeUndefined();
expect(toCipherType(undefined)).toBeUndefined();
});
});
});

View File

@@ -1,9 +1,60 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum CipherType {
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5,
const _CipherType = Object.freeze({
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const);
type _CipherType = typeof _CipherType;
export type CipherType = _CipherType[keyof _CipherType];
// FIXME: Update typing of `CipherType` to be `Record<keyof _CipherType, CipherType>` which is ADR-0025 compliant when the TypeScript version is at least 5.8.
export const CipherType: typeof _CipherType = _CipherType;
/**
* Reverse mapping of Cipher Types to their associated names.
* Prefer using {@link toCipherTypeName} rather than accessing this object directly.
*
* When represented as an enum in TypeScript, this mapping was provided
* by default. Now using a constant object it needs to be defined manually.
*/
export const cipherTypeNames = Object.freeze(
Object.fromEntries(Object.entries(CipherType).map(([key, value]) => [value, key])),
) as Readonly<Record<CipherType, keyof typeof CipherType>>;
/**
* Returns the associated name for the cipher type, will throw when the name is not found.
*/
export function toCipherTypeName(type: CipherType): keyof typeof CipherType | undefined {
const name = cipherTypeNames[type];
return name;
}
/**
* @returns `true` if the value is a valid `CipherType`, `false` otherwise.
*/
export const isCipherType = (value: unknown): value is CipherType => {
return Object.values(CipherType).includes(value as CipherType);
};
/**
* Converts a value to a `CipherType` if it is valid, otherwise returns `null`.
*/
export const toCipherType = (value: unknown): CipherType | undefined => {
if (isCipherType(value)) {
return value;
}
if (typeof value === "string") {
const valueAsInt = parseInt(value, 10);
if (isCipherType(valueAsInt)) {
return valueAsInt;
}
}
return undefined;
};

View File

@@ -1,8 +1,12 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum FieldType {
Text = 0,
Hidden = 1,
Boolean = 2,
Linked = 3,
}
const _FieldType = Object.freeze({
Text: 0,
Hidden: 1,
Boolean: 2,
Linked: 3,
} as const);
type _FieldType = typeof _FieldType;
export type FieldType = _FieldType[keyof _FieldType];
export const FieldType: Record<keyof _FieldType, FieldType> = _FieldType;

View File

@@ -1,46 +1,48 @@
import { UnionOfValues } from "../types/union-of-values";
export type LinkedIdType = LoginLinkedId | CardLinkedId | IdentityLinkedId;
// LoginView
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum LoginLinkedId {
Username = 100,
Password = 101,
}
export const LoginLinkedId = {
Username: 100,
Password: 101,
} as const;
export type LoginLinkedId = UnionOfValues<typeof LoginLinkedId>;
// CardView
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum CardLinkedId {
CardholderName = 300,
ExpMonth = 301,
ExpYear = 302,
Code = 303,
Brand = 304,
Number = 305,
}
export const CardLinkedId = {
CardholderName: 300,
ExpMonth: 301,
ExpYear: 302,
Code: 303,
Brand: 304,
Number: 305,
} as const;
export type CardLinkedId = UnionOfValues<typeof CardLinkedId>;
// IdentityView
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum IdentityLinkedId {
Title = 400,
MiddleName = 401,
Address1 = 402,
Address2 = 403,
Address3 = 404,
City = 405,
State = 406,
PostalCode = 407,
Country = 408,
Company = 409,
Email = 410,
Phone = 411,
Ssn = 412,
Username = 413,
PassportNumber = 414,
LicenseNumber = 415,
FirstName = 416,
LastName = 417,
FullName = 418,
}
export const IdentityLinkedId = {
Title: 400,
MiddleName: 401,
Address1: 402,
Address2: 403,
Address3: 404,
City: 405,
State: 406,
PostalCode: 407,
Country: 408,
Company: 409,
Email: 410,
Phone: 411,
Ssn: 412,
Username: 413,
PassportNumber: 414,
LicenseNumber: 415,
FirstName: 416,
LastName: 417,
FullName: 418,
} as const;
export type IdentityLinkedId = UnionOfValues<typeof IdentityLinkedId>;

View File

@@ -1,5 +1,7 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum SecureNoteType {
Generic = 0,
}
import { UnionOfValues } from "../types/union-of-values";
export const SecureNoteType = {
Generic: 0,
} as const;
export type SecureNoteType = UnionOfValues<typeof SecureNoteType>;

View File

@@ -57,7 +57,7 @@ export class CipherData {
this.organizationUseTotp = response.organizationUseTotp;
this.favorite = response.favorite;
this.revisionDate = response.revisionDate;
this.type = response.type;
this.type = response.type as CipherType;
this.name = response.name;
this.notes = response.notes;
this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds;

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../models/response/base.response";
import { CipherType } from "../../enums";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CardApi } from "../api/card.api";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
@@ -17,7 +18,7 @@ export class CipherResponse extends BaseResponse {
id: string;
organizationId: string;
folderId: string;
type: number;
type: CipherType;
name: string;
notes: string;
fields: FieldApi[];

View File

@@ -7,10 +7,9 @@ import { CollectionService, CollectionView } from "@bitwarden/admin-console/comm
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherView } from "../models/view/cipher.view";
@@ -24,7 +23,6 @@ describe("CipherAuthorizationService", () => {
const mockCollectionService = mock<CollectionService>();
const mockOrganizationService = mock<OrganizationService>();
const mockConfigService = mock<ConfigService>();
const mockUserId = Utils.newGuid() as UserId;
let mockAccountService: FakeAccountService;
@@ -70,10 +68,7 @@ describe("CipherAuthorizationService", () => {
mockCollectionService,
mockOrganizationService,
mockAccountService,
mockConfigService,
);
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
});
describe("canRestoreCipher$", () => {
@@ -90,7 +85,7 @@ describe("CipherAuthorizationService", () => {
});
});
it("should return true if isAdminConsleAction and user can edit all ciphers in the org", (done) => {
it("should return true if isAdminConsoleAction and user can edit all ciphers in the org", (done) => {
const cipher = createMockCipher("org1", ["col1"]) as CipherView;
const organization = createMockOrganization({ canEditAllCiphers: true });
mockOrganizationService.organizations$.mockReturnValue(
@@ -145,15 +140,6 @@ describe("CipherAuthorizationService", () => {
});
describe("canDeleteCipher$", () => {
it("should return true if cipher has no organizationId", (done) => {
const cipher = createMockCipher(null, []) as CipherView;
cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => {
expect(result).toBe(true);
done();
});
});
it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ canEditUnassignedCiphers: true });
@@ -161,7 +147,7 @@ describe("CipherAuthorizationService", () => {
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => {
cipherAuthorizationService.canDeleteCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
done();
});
@@ -174,7 +160,7 @@ describe("CipherAuthorizationService", () => {
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => {
cipherAuthorizationService.canDeleteCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
done();
@@ -186,136 +172,32 @@ describe("CipherAuthorizationService", () => {
const organization = createMockOrganization({ canEditUnassignedCiphers: false });
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => {
cipherAuthorizationService.canDeleteCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(false);
done();
});
});
it("should return true if activeCollectionId is provided and has manage permission", (done) => {
const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView;
const activeCollectionId = "col1" as CollectionId;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
const allCollections = [
createMockCollection("col1", true),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
cipherAuthorizationService
.canDeleteCipher$(cipher, [activeCollectionId])
.subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([
"col1",
"col2",
] as CollectionId[]);
done();
});
});
it("should return false if activeCollectionId is provided and manage permission is not present", (done) => {
const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView;
const activeCollectionId = "col1" as CollectionId;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
const allCollections = [
createMockCollection("col1", false),
createMockCollection("col2", true),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
cipherAuthorizationService
.canDeleteCipher$(cipher, [activeCollectionId])
.subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([
"col1",
"col2",
] as CollectionId[]);
done();
});
});
it("should return true if any collection has manage permission", (done) => {
const cipher = createMockCipher("org1", ["col1", "col2", "col3"]) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
const allCollections = [
createMockCollection("col1", false),
createMockCollection("col2", true),
createMockCollection("col3", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([
"col1",
"col2",
"col3",
] as CollectionId[]);
done();
});
});
it("should return false if no collection has manage permission", (done) => {
const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
const allCollections = [
createMockCollection("col1", false),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([
"col1",
"col2",
] as CollectionId[]);
done();
});
});
it("should return true if feature flag enabled and cipher.permissions.delete is true", (done) => {
it("should return true when cipher.permissions.delete is true", (done) => {
const cipher = createMockCipher("org1", [], true, {
delete: true,
} as CipherPermissionsApi) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
cipherAuthorizationService.canDeleteCipher$(cipher, [], false).subscribe((result) => {
cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});
it("should return false if feature flag enabled and cipher.permissions.delete is false", (done) => {
it("should return false when cipher.permissions.delete is false", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
cipherAuthorizationService.canDeleteCipher$(cipher, [], false).subscribe((result) => {
cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});

View File

@@ -1,15 +1,13 @@
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { map, Observable, of, shareReplay, switchMap } from "rxjs";
// 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 { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CollectionId } from "@bitwarden/common/types/guid";
import { getUserId } from "../../auth/services/account.service";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { Cipher } from "../models/domain/cipher";
import { CipherView } from "../models/view/cipher.view";
@@ -26,14 +24,12 @@ export abstract class CipherAuthorizationService {
* Determines if the user can delete the specified cipher.
*
* @param {CipherLike} cipher - The cipher object to evaluate for deletion permissions.
* @param {CollectionId[]} [allowedCollections] - Optional. The selected collection id from the vault filter.
* @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console.
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can delete the cipher.
*/
abstract canDeleteCipher$: (
cipher: CipherLike,
allowedCollections?: CollectionId[],
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
@@ -72,7 +68,6 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
private collectionService: CollectionService,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
) {}
private organization$ = (cipher: CipherLike) =>
@@ -86,48 +81,21 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
*
* {@link CipherAuthorizationService.canDeleteCipher$}
*/
canDeleteCipher$(
cipher: CipherLike,
allowedCollections?: CollectionId[],
isAdminConsoleAction?: boolean,
): Observable<boolean> {
return combineLatest([
this.organization$(cipher),
this.configService.getFeatureFlag$(FeatureFlag.LimitItemDeletion),
]).pipe(
switchMap(([organization, featureFlagEnabled]) => {
canDeleteCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable<boolean> {
return this.organization$(cipher).pipe(
map((organization) => {
if (isAdminConsoleAction) {
// If the user is an admin, they can delete an unassigned cipher
if (!cipher.collectionIds || cipher.collectionIds.length === 0) {
return of(organization?.canEditUnassignedCiphers === true);
return organization?.canEditUnassignedCiphers === true;
}
if (organization?.canEditAllCiphers) {
return of(true);
return true;
}
}
if (featureFlagEnabled) {
return of(cipher.permissions.delete);
}
if (cipher.organizationId == null) {
return of(true);
}
return this.collectionService
.decryptedCollectionViews$(cipher.collectionIds as CollectionId[])
.pipe(
map((allCollections) => {
const shouldFilter = allowedCollections?.some(Boolean);
const collections = shouldFilter
? allCollections.filter((c) => allowedCollections?.includes(c.id as CollectionId))
: allCollections;
return collections.some((collection) => collection.manage);
}),
);
return cipher.permissions.delete;
}),
);
}

View File

@@ -852,7 +852,7 @@ export class CipherService implements CipherServiceAbstraction {
const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId);
try {
const response = await this.apiService.putShareCiphers(request);
const responseMap = new Map(response.map((c) => [c.id, c]));
const responseMap = new Map(response.data.map((r) => [r.id, r]));
encCiphers.forEach((cipher) => {
const matchingCipher = responseMap.get(cipher.id);

View File

@@ -1,13 +1,15 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum SecurityTaskStatus {
import { UnionOfValues } from "../../types/union-of-values";
export const SecurityTaskStatus = {
/**
* Default status for newly created tasks that have not been completed.
*/
Pending = 0,
Pending: 0,
/**
* Status when a task is considered complete and has no remaining actions
*/
Completed = 1,
}
Completed: 1,
} as const;
export type SecurityTaskStatus = UnionOfValues<typeof SecurityTaskStatus>;

View File

@@ -1,8 +1,10 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum SecurityTaskType {
import { UnionOfValues } from "../../types/union-of-values";
export const SecurityTaskType = {
/**
* Task to update a cipher's password that was found to be at-risk by an administrator
*/
UpdateAtRiskCredential = 0,
}
UpdateAtRiskCredential: 0,
} as const;
export type SecurityTaskType = UnionOfValues<typeof SecurityTaskType>;

View File

@@ -7,7 +7,6 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { NotificationType } from "@bitwarden/common/enums";
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Message, MessageListener } from "@bitwarden/common/platform/messaging";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
@@ -25,7 +24,6 @@ describe("Default task service", () => {
const userId = "user-id" as UserId;
const mockApiSend = jest.fn();
const mockGetAllOrgs$ = jest.fn();
const mockGetFeatureFlag$ = jest.fn();
const mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
const mockNotifications$ = new Subject<readonly [NotificationResponse, UserId]>();
const mockMessages$ = new Subject<Message<Record<string, unknown>>>();
@@ -34,14 +32,12 @@ describe("Default task service", () => {
beforeEach(async () => {
mockApiSend.mockClear();
mockGetAllOrgs$.mockClear();
mockGetFeatureFlag$.mockClear();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
service = new DefaultTaskService(
fakeStateProvider,
{ send: mockApiSend } as unknown as ApiService,
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
{ authStatuses$: mockAuthStatuses$.asObservable() } as unknown as AuthService,
{ notifications$: mockNotifications$.asObservable() } as unknown as NotificationsService,
{ allMessages$: mockMessages$.asObservable() } as unknown as MessageListener,
@@ -50,7 +46,6 @@ describe("Default task service", () => {
describe("tasksEnabled$", () => {
it("should emit true if any organization uses risk insights", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
@@ -70,7 +65,6 @@ describe("Default task service", () => {
});
it("should emit false if no organization uses risk insights", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
@@ -88,28 +82,10 @@ describe("Default task service", () => {
expect(result).toBe(false);
});
it("should emit false if the feature flag is off", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: true,
},
] as Organization[]),
);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
expect(result).toBe(false);
});
});
describe("tasks$", () => {
beforeEach(() => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
@@ -182,7 +158,6 @@ describe("Default task service", () => {
describe("pendingTasks$", () => {
beforeEach(() => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{

View File

@@ -15,9 +15,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { NotificationType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -43,20 +41,14 @@ export class DefaultTaskService implements TaskService {
private stateProvider: StateProvider,
private apiService: ApiService,
private organizationService: OrganizationService,
private configService: ConfigService,
private authService: AuthService,
private notificationService: NotificationsService,
private messageListener: MessageListener,
) {}
tasksEnabled$ = perUserCache$((userId) => {
return combineLatest([
this.organizationService
.organizations$(userId)
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))),
this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks),
]).pipe(
map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled),
return this.organizationService.organizations$(userId).pipe(
map((orgs) => orgs.some((o) => o.useRiskInsights)),
distinctUntilChanged(),
);
});

View File

@@ -0,0 +1,2 @@
/** Creates a union type consisting of all values within the record. */
export type UnionOfValues<T extends Record<string, unknown>> = T[keyof T];

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ButtonModule } from "./index";
@@ -13,21 +11,18 @@ describe("Button", () => {
let disabledButtonDebugElement: DebugElement;
let linkDebugElement: DebugElement;
beforeEach(waitForAsync(() => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ButtonModule],
declarations: [TestApp],
imports: [TestApp],
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.compileComponents();
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
buttonDebugElement = fixture.debugElement.query(By.css("button"));
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
linkDebugElement = fixture.debugElement.query(By.css("a"));
}));
});
it("should not be disabled when loading and disabled are false", () => {
testAppComponent.loading = false;
@@ -85,11 +80,11 @@ describe("Button", () => {
<button id="disabled" type="button" bitButton disabled>Button</button>
`,
standalone: false,
imports: [ButtonModule],
})
class TestApp {
buttonType: string;
block: boolean;
disabled: boolean;
loading: boolean;
buttonType?: string;
block?: boolean;
disabled?: boolean;
loading?: boolean;
}

View File

@@ -1,18 +1,15 @@
import { DIALOG_DATA, DialogModule, DialogRef } from "@angular/cdk/dialog";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
import { DialogComponent } from "./dialog/dialog.component";
import { DialogModule } from "./dialog.module";
import { DialogService } from "./dialog.service";
import { DialogCloseDirective } from "./directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "./directives/dialog-title-container.directive";
interface Animal {
animal: string;
@@ -20,7 +17,7 @@ interface Animal {
@Component({
template: `<button bitButton type="button" (click)="openDialog()">Open Dialog</button>`,
standalone: false,
imports: [ButtonModule],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
@@ -50,7 +47,7 @@ class StoryDialogComponent {
</ng-container>
</bit-dialog>
`,
standalone: false,
imports: [DialogModule, ButtonModule],
})
class StoryDialogContentComponent {
constructor(
@@ -68,17 +65,8 @@ export default {
component: StoryDialogComponent,
decorators: [
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
ButtonModule,
DialogModule,
IconButtonModule,
DialogCloseDirective,
DialogComponent,
DialogTitleContainerDirective,
],
providers: [
provideAnimations(),
DialogService,
{
provide: I18nService,

View File

@@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -31,7 +31,7 @@ import { DialogModule } from "../../dialog.module";
</bit-callout>
}
`,
standalone: false,
imports: [ButtonModule, CalloutModule, DialogModule],
})
class StoryDialogComponent {
protected dialogs: { title: string; dialogs: SimpleDialogOptions[] }[] = [
@@ -147,11 +147,9 @@ export default {
title: "Component Library/Dialogs/Service/SimpleConfigurable",
component: StoryDialogComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, BrowserAnimationsModule, DialogModule, CalloutModule],
}),
applicationConfig({
providers: [
provideAnimations(),
{
provide: I18nService,
useFactory: () => {

View File

@@ -1,8 +1,8 @@
<div
class="tw-my-4 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
class="tw-my-4 tw-pb-6 tw-pt-8 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-100 tw-shadow-xl tw-bg-text-contrast tw-text-main"
@fadeIn
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
<div class="tw-flex tw-px-6 tw-flex-col tw-items-center tw-gap-2 tw-text-center">
@if (!hideIcon()) {
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
@@ -20,13 +20,11 @@
</h1>
</div>
<div
class="tw-overflow-y-auto tw-px-4 tw-pb-4 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
class="tw-overflow-y-auto tw-px-6 tw-mb-6 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
>
<ng-content select="[bitDialogContent]"></ng-content>
</div>
<div
class="tw-flex tw-flex-row tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-p-4"
>
<div class="tw-flex tw-flex-col tw-gap-2 tw-px-6">
<ng-content select="[bitDialogFooter]"></ng-content>
</div>
</div>

View File

@@ -1,13 +1,11 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../../button";
import { IconButtonModule } from "../../icon-button";
import { SharedModule } from "../../shared/shared.module";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { DialogModule } from "../dialog.module";
import { DialogService } from "../dialog.service";
@@ -18,7 +16,7 @@ interface Animal {
@Component({
template: `<button type="button" bitButton (click)="openDialog()">Open Simple Dialog</button>`,
standalone: false,
imports: [ButtonModule],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
@@ -49,7 +47,7 @@ class StoryDialogComponent {
</ng-container>
</bit-simple-dialog>
`,
standalone: false,
imports: [ButtonModule, DialogModule],
})
class StoryDialogContentComponent {
constructor(
@@ -67,15 +65,8 @@ export default {
component: StoryDialogComponent,
decorators: [
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
IconButtonModule,
ButtonModule,
BrowserAnimationsModule,
DialogModule,
],
providers: [
provideAnimations(),
DialogService,
{
provide: I18nService,

View File

@@ -6,7 +6,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { IconButtonModule } from "../icon-button";
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BitFormFieldControl } from "./form-field-control";
@@ -25,7 +24,7 @@ import { BitPasswordInputToggleDirective } from "./password-input-toggle.directi
</bit-form-field>
</form>
`,
standalone: false,
imports: [FormFieldModule, IconButtonModule],
})
class TestFormFieldComponent {}
@@ -37,8 +36,7 @@ describe("PasswordInputToggle", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormFieldModule, IconButtonModule, InputModule],
declarations: [TestFormFieldComponent],
imports: [TestFormFieldComponent],
providers: [
{
provide: I18nService,

View File

@@ -41,3 +41,4 @@ export * from "./toast";
export * from "./toggle-group";
export * from "./typography";
export * from "./utils";
export * from "./stepper";

View File

@@ -19,7 +19,6 @@ import { FocusableElement } from "../shared/focusable-element";
*/
@Directive({
selector: "[appAutofocus], [bitAutofocus]",
standalone: false,
})
export class AutofocusDirective implements AfterContentChecked {
@Input() set appAutofocus(condition: boolean | string) {

View File

@@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
@@ -16,19 +16,16 @@ describe("Menu", () => {
// The overlay is created outside the root debugElement, so we need to query its parent
const getBitMenuPanel = () => document.querySelector(".bit-menu-panel");
beforeEach(waitForAsync(() => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MenuModule],
declarations: [TestApp],
imports: [TestApp],
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.compileComponents();
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
}));
});
it("should open when the trigger is clicked", async () => {
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
@@ -73,6 +70,6 @@ describe("Menu", () => {
<a id="item2" bitMenuItem>Item 2</a>
</bit-menu>
`,
standalone: false,
imports: [MenuModule],
})
class TestApp {}

View File

@@ -1,7 +1,7 @@
<div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6">
<div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center">
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
<h3 class="tw-font-semibold tw-text-center">
<h3 class="tw-font-semibold tw-text-center tw-mt-4">
<ng-content select="[slot=title]"></ng-content>
</h3>
<p class="tw-text-center">

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -17,26 +15,23 @@ describe("RadioButton", () => {
let testAppComponent: TestApp;
let radioButton: HTMLInputElement;
beforeEach(waitForAsync(() => {
beforeEach(async () => {
mockGroupComponent = new MockedButtonGroupComponent();
TestBed.configureTestingModule({
imports: [RadioButtonModule],
declarations: [TestApp],
imports: [TestApp],
providers: [
{ provide: RadioGroupComponent, useValue: mockGroupComponent },
{ provide: I18nService, useValue: new I18nMockService({}) },
],
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.compileComponents();
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance;
radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement;
}));
});
it("should emit value when clicking on radio button", () => {
testAppComponent.value = "value";
@@ -77,7 +72,7 @@ class MockedButtonGroupComponent implements Partial<RadioGroupComponent> {
@Component({
selector: "test-app",
template: `<bit-radio-button [value]="value"><bit-label>Element</bit-label></bit-radio-button>`,
standalone: false,
imports: [RadioButtonModule],
})
class TestApp {
value?: string;

View File

@@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
@@ -16,16 +16,13 @@ describe("RadioGroupComponent", () => {
let buttonElements: RadioButtonComponent[];
let radioButtons: HTMLInputElement[];
beforeEach(waitForAsync(() => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule, RadioButtonModule],
declarations: [TestApp],
imports: [TestApp],
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.compileComponents();
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance;
@@ -37,7 +34,7 @@ describe("RadioGroupComponent", () => {
.map((e) => e.nativeElement);
fixture.detectChanges();
}));
});
it("should select second element when setting selected to second", async () => {
testAppComponent.selected = "second";
@@ -75,7 +72,7 @@ describe("RadioGroupComponent", () => {
<bit-radio-button value="third">Third</bit-radio-button>
</bit-radio-group>
`,
standalone: false,
imports: [FormsModule, RadioButtonModule],
})
class TestApp {
selected?: string;

View File

@@ -0,0 +1 @@
export * from "./resize-observer.directive";

View File

@@ -0,0 +1,30 @@
import { Directive, ElementRef, EventEmitter, Output, OnDestroy } from "@angular/core";
@Directive({
selector: "[resizeObserver]",
standalone: true,
})
export class ResizeObserverDirective implements OnDestroy {
private observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === this.el.nativeElement) {
this._resizeCallback(entry);
}
}
});
@Output()
resize = new EventEmitter();
constructor(private el: ElementRef) {
this.observer.observe(this.el.nativeElement);
}
_resizeCallback(entry: ResizeObserverEntry) {
this.resize.emit(entry);
}
ngOnDestroy() {
this.observer.unobserve(this.el.nativeElement);
}
}

View File

@@ -0,0 +1 @@
export * from "./stepper.module";

View File

@@ -0,0 +1,3 @@
<ng-template>
<ng-content></ng-content>
</ng-template>

View File

@@ -0,0 +1,16 @@
import { CdkStep, CdkStepper } from "@angular/cdk/stepper";
import { Component, input } from "@angular/core";
@Component({
selector: "bit-step",
templateUrl: "step.component.html",
providers: [{ provide: CdkStep, useExisting: StepComponent }],
standalone: true,
})
export class StepComponent extends CdkStep {
subLabel = input();
constructor(stepper: CdkStepper) {
super(stepper);
}
}

View File

@@ -0,0 +1,30 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { StepComponent } from "./step.component";
import { StepperComponent } from "./stepper.component";
export default {
title: "Component Library/Stepper/Step",
component: StepComponent,
decorators: [
moduleMetadata({
imports: [StepperComponent],
}),
],
} as Meta;
export const Default: StoryObj<StepComponent> = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-stepper>
<bit-step
label="This is the label"
subLabel="This is the sub label"
>
<p>Your custom step content appears in here. You can add whatever content you'd like</p>
</bit-step>
</bit-stepper>
`,
}),
};

View File

@@ -0,0 +1,126 @@
<div resizeObserver (resize)="handleResize($event)">
@if (orientation === "horizontal") {
<div role="tablist">
<div class="tw-flex tw-gap-8 tw-justify-between">
@for (step of steps; track $index; let isLast = $last) {
@let isCurrentStepDisabled = isStepDisabled($index);
<button
type="button"
[disabled]="isCurrentStepDisabled"
(click)="selectStepByIndex($index)"
class="tw-flex tw-p-3 tw-items-center tw-border-none tw-bg-transparent tw-shrink-0"
[ngClass]="{
'hover:tw-bg-secondary-100': !isCurrentStepDisabled && step.editable,
}"
[attr.aria-selected]="selectedIndex === $index"
[attr.aria-controls]="contentId + $index"
role="tab"
>
@if (step.completed) {
<span
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
</span>
} @else {
<span
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
[ngClass]="{
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
'tw-bg-secondary-300 tw-text-main':
selectedIndex !== $index && !isCurrentStepDisabled && step.editable,
'tw-bg-transparent tw-text-muted': isCurrentStepDisabled,
}"
>
{{ $index + 1 }}
</span>
}
<div class="tw-leading-snug tw-text-left">
<p bitTypography="body1" class="tw-m-0">{{ step.label }}</p>
@if (step.subLabel()) {
<p bitTypography="body2" class="tw-m-0 tw-mt-1 tw-text-muted">
{{ step.subLabel() }}
</p>
}
</div>
</button>
@if (!isLast) {
<div
class="after:tw-left-0 after:tw-top-[50%] after:-tw-translate-y-[50%] after:tw-h-[2px] after:tw-w-full after:tw-absolute after:tw-bg-secondary-300 after:tw-content-[''] tw-relative tw-w-full"
></div>
}
}
</div>
</div>
@for (step of steps; track $index; let isLast = $last) {
<div role="tabpanel" [attr.id]="contentId + $index" [hidden]="!selected">
@if (selectedIndex === $index) {
<div [ngTemplateOutlet]="selected.content"></div>
}
</div>
}
} @else {
@for (step of steps; track $index; let isLast = $last) {
@let isCurrentStepDisabled = isStepDisabled($index);
<button
type="button"
[disabled]="isCurrentStepDisabled"
(click)="selectStepByIndex($index)"
class="tw-flex tw-p-3 tw-w-full tw-items-center tw-border-none tw-bg-transparent"
[ngClass]="{
'hover:tw-bg-secondary-100': !isCurrentStepDisabled && step.editable,
}"
[attr.id]="contentId + 'accordion' + $index"
[attr.aria-expanded]="selectedIndex === $index"
[attr.aria-controls]="contentId + $index"
>
@if (step.completed) {
<span
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
</span>
} @else {
<span
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
[ngClass]="{
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
'tw-bg-secondary-300 tw-text-main':
selectedIndex !== $index && !isCurrentStepDisabled && step.editable,
'tw-bg-transparent tw-text-muted': isCurrentStepDisabled,
}"
>
{{ $index + 1 }}
</span>
}
<div class="tw-leading-snug tw-text-left">
<p bitTypography="body1" class="tw-m-0">{{ step.label }}</p>
@if (step.subLabel()) {
<div bitTypography="body2" class="tw-m-0 tw-mt-1 tw-text-muted">
{{ step.subLabel() }}
</div>
}
</div>
</button>
<div
[attr.id]="contentId + $index"
[hidden]="!selected"
[attr.aria-labelledby]="contentId + 'accordion' + $index"
role="region"
>
<div
class="tw-ms-7 tw-border-solid tw-border-0 tw-border-s tw-border-secondary-300"
[ngClass]="{ 'tw-min-h-6': !isLast }"
>
@if (selectedIndex === $index) {
<div class="tw-ps-8 tw-py-2">
<div [ngTemplateOutlet]="selected.content"></div>
</div>
}
</div>
</div>
}
}
</div>

View File

@@ -0,0 +1,88 @@
import { Directionality } from "@angular/cdk/bidi";
import { CdkStepper, StepperOrientation } from "@angular/cdk/stepper";
import { CommonModule } from "@angular/common";
import { ChangeDetectorRef, Component, ElementRef, Input, QueryList } from "@angular/core";
import { ResizeObserverDirective } from "../resize-observer";
import { TypographyModule } from "../typography";
import { StepComponent } from "./step.component";
/**
* The `<bit-stepper>` component extends the
* [Angular CdkStepper](https://material.angular.io/cdk/stepper/api#CdkStepper) component
*/
@Component({
selector: "bit-stepper",
templateUrl: "stepper.component.html",
providers: [{ provide: CdkStepper, useExisting: StepperComponent }],
imports: [CommonModule, ResizeObserverDirective, TypographyModule],
standalone: true,
})
export class StepperComponent extends CdkStepper {
// Need to reimplement the constructor to fix an invalidFactoryDep error in Storybook
// @see https://github.com/storybookjs/storybook/issues/23534#issuecomment-2042888436
constructor(
_dir: Directionality,
_changeDetectorRef: ChangeDetectorRef,
_elementRef: ElementRef<HTMLElement>,
) {
super(_dir, _changeDetectorRef, _elementRef);
}
private resizeWidthsMap = new Map([
[2, 600],
[3, 768],
[4, 900],
]);
override readonly steps!: QueryList<StepComponent>;
private internalOrientation: StepperOrientation | undefined = undefined;
private initialOrientation: StepperOrientation | undefined = undefined;
// overriding CdkStepper orientation input so we can default to vertical
@Input()
override get orientation() {
return this.internalOrientation || "vertical";
}
override set orientation(value: StepperOrientation) {
if (!this.internalOrientation) {
// tracking the first value of orientation. We want to handle resize events if it's 'horizontal'.
// If it's 'vertical' don't change the orientation to 'horizontal' when resizing
this.initialOrientation = value;
}
this.internalOrientation = value;
}
handleResize(entry: ResizeObserverEntry) {
if (this.initialOrientation === "horizontal") {
const stepperContainerWidth = entry.contentRect.width;
const numberOfSteps = this.steps.length;
const breakpoint = this.resizeWidthsMap.get(numberOfSteps) || 450;
this.orientation = stepperContainerWidth < breakpoint ? "vertical" : "horizontal";
// This is a method of CdkStepper. Their docs define it as: 'Marks the component to be change detected'
this._stateChanged();
}
}
isStepDisabled(index: number) {
if (this.selectedIndex !== index) {
return this.selectedIndex === index - 1
? !this.steps.find((_, i) => i == index - 1)?.completed
: true;
}
return false;
}
selectStepByIndex(index: number): void {
this.selectedIndex = index;
}
/**
* UID for `[attr.aria-controls]`
*/
protected contentId = Math.random().toString(36).substring(2);
}

View File

@@ -0,0 +1,35 @@
import { Meta, Story, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./stepper.stories";
<Meta of={stories} />
<Title />
<Description />
<Primary />
<Controls />
## Step Component
The `<bit-step>` component extends the
[Angular CdkStep](https://material.angular.io/cdk/stepper/api#CdkStep) component
The following additional Inputs are accepted:
| Input | Type | Description |
| ---------- | ------ | -------------------------------------------------------------- |
| `subLabel` | string | An optional supplemental label to display below the main label |
In order for the stepper component to work as intended, its children must be instances of
`<bit-step>`.
```html
<bit-stepper>
<bit-step label="This is the label" subLabel="This is the sub label">
Your content here
</bit-step>
<bit-step label="Another label"> Your content here </bit-step>
<bit-step label="The last label"> Your content here </bit-step>
</bit-stepper>
```

View File

@@ -0,0 +1,10 @@
import { NgModule } from "@angular/core";
import { StepComponent } from "./step.component";
import { StepperComponent } from "./stepper.component";
@NgModule({
imports: [StepperComponent, StepComponent],
exports: [StepperComponent, StepComponent],
})
export class StepperModule {}

View File

@@ -0,0 +1,70 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { ButtonComponent } from "../button";
import { StepComponent } from "./step.component";
import { StepperComponent } from "./stepper.component";
export default {
title: "Component Library/Stepper",
component: StepperComponent,
decorators: [
moduleMetadata({
imports: [ButtonComponent, StepComponent],
}),
],
} as Meta;
export const Default: StoryObj<StepperComponent> = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-stepper [orientation]="orientation">
<bit-step
label="This is the label"
subLabel="This is the sub label"
>
<p>Your custom step content appears in here. You can add whatever content you'd like</p>
<button
type="button"
bitButton
buttonType="primary"
>
Some button label
</button>
</bit-step>
<bit-step
label="Another label"
>
<p>Another step</p>
<button
type="button"
bitButton
buttonType="primary"
>
Some button label
</button>
</bit-step>
<bit-step
label="The last label"
>
<p>The last step</p>
<button
type="button"
bitButton
buttonType="primary"
>
Some button label
</button>
</bit-step>
</bit-stepper>
`,
}),
};
export const Horizontal: StoryObj<StepperComponent> = {
...Default,
args: {
orientation: "horizontal",
},
};

Some files were not shown because too many files have changed in this diff Show More