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:
@@ -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">
|
||||
|
||||
@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
TextDragDirective,
|
||||
CopyClickDirective,
|
||||
A11yTitleDirective,
|
||||
AutofocusDirective,
|
||||
],
|
||||
declarations: [
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
AutofocusDirective,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
|
||||
36
libs/angular/src/platform/i18n/document-lang.setter.spec.ts
Normal file
36
libs/angular/src/platform/i18n/document-lang.setter.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
26
libs/angular/src/platform/i18n/document-lang.setter.ts
Normal file
26
libs/angular/src/platform/i18n/document-lang.setter.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
1
libs/angular/src/platform/i18n/index.ts
Normal file
1
libs/angular/src/platform/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DocumentLangSetter } from "./document-lang.setter";
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]) => {
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface AnonLayoutWrapperData {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "anon-layout-wrapper.component.html",
|
||||
imports: [AnonLayoutComponent, RouterModule],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -11,7 +11,6 @@ export type FingerprintDialogData = {
|
||||
|
||||
@Component({
|
||||
templateUrl: "fingerprint-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [JslibModule, ButtonModule, DialogModule],
|
||||
})
|
||||
export class FingerprintDialogComponent {
|
||||
|
||||
@@ -83,7 +83,6 @@ interface InputPasswordForm {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-input-password",
|
||||
templateUrl: "./input-password.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,7 +51,6 @@ enum State {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "./login-decryption-options.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -58,7 +58,6 @@ export enum LoginUiState {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "./login.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "./password-hint.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -62,7 +62,6 @@ interface QueryParams {
|
||||
* This component handles the SSO flow.
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "sso.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "./two-factor-auth-duo-component.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-auth-duo",
|
||||
template: "",
|
||||
imports: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface WebAuthnResult {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-auth-webauthn",
|
||||
templateUrl: "two-factor-auth-webauthn.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,7 +32,6 @@ export type TwoFactorOptionsDialogResult = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-options",
|
||||
templateUrl: "two-factor-options.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -32,7 +32,6 @@ import { UserVerificationFormInputComponent } from "./user-verification-form-inp
|
||||
|
||||
@Component({
|
||||
templateUrl: "user-verification-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
clientSecret,
|
||||
]);
|
||||
|
||||
await this.keyService.refreshAdditionalKeys();
|
||||
await this.keyService.refreshAdditionalKeys(userId);
|
||||
}
|
||||
|
||||
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
66
libs/common/src/vault/enums/cipher-type.spec.ts
Normal file
66
libs/common/src/vault/enums/cipher-type.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
2
libs/common/src/vault/types/union-of-values.ts
Normal file
2
libs/common/src/vault/types/union-of-values.ts
Normal 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];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,3 +41,4 @@ export * from "./toast";
|
||||
export * from "./toggle-group";
|
||||
export * from "./typography";
|
||||
export * from "./utils";
|
||||
export * from "./stepper";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
libs/components/src/resize-observer/index.ts
Normal file
1
libs/components/src/resize-observer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./resize-observer.directive";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
1
libs/components/src/stepper/index.ts
Normal file
1
libs/components/src/stepper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./stepper.module";
|
||||
3
libs/components/src/stepper/step.component.html
Normal file
3
libs/components/src/stepper/step.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<ng-template>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
16
libs/components/src/stepper/step.component.ts
Normal file
16
libs/components/src/stepper/step.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
libs/components/src/stepper/step.stories.ts
Normal file
30
libs/components/src/stepper/step.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
126
libs/components/src/stepper/stepper.component.html
Normal file
126
libs/components/src/stepper/stepper.component.html
Normal 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>
|
||||
88
libs/components/src/stepper/stepper.component.ts
Normal file
88
libs/components/src/stepper/stepper.component.ts
Normal 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);
|
||||
}
|
||||
35
libs/components/src/stepper/stepper.mdx
Normal file
35
libs/components/src/stepper/stepper.mdx
Normal 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>
|
||||
```
|
||||
10
libs/components/src/stepper/stepper.module.ts
Normal file
10
libs/components/src/stepper/stepper.module.ts
Normal 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 {}
|
||||
70
libs/components/src/stepper/stepper.stories.ts
Normal file
70
libs/components/src/stepper/stepper.stories.ts
Normal 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
Reference in New Issue
Block a user