1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

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

This commit is contained in:
Patrick Pimentel
2025-06-23 15:39:09 -04:00
619 changed files with 24974 additions and 5442 deletions

View File

@@ -13,8 +13,10 @@ import {
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -25,12 +27,14 @@ describe("AuthGuard", () => {
authStatus: AuthenticationStatus,
forceSetPasswordReason: ForceSetPasswordReason,
keyConnectorServiceRequiresAccountConversion: boolean = false,
featureFlag: FeatureFlag | null = null,
) => {
const authService: MockProxy<AuthService> = mock<AuthService>();
authService.getAuthStatus.mockResolvedValue(authStatus);
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
const keyConnectorService: MockProxy<KeyConnectorService> = mock<KeyConnectorService>();
keyConnectorService.convertAccountRequired$ = of(keyConnectorServiceRequiresAccountConversion);
const configService: MockProxy<ConfigService> = mock<ConfigService>();
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
@@ -45,6 +49,12 @@ describe("AuthGuard", () => {
),
);
if (featureFlag) {
configService.getFeatureFlag.mockResolvedValue(true);
} else {
configService.getFeatureFlag.mockResolvedValue(false);
}
const forceSetPasswordReasonSubject = new BehaviorSubject<ForceSetPasswordReason>(
forceSetPasswordReason,
);
@@ -59,7 +69,10 @@ describe("AuthGuard", () => {
{ path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] },
{ path: "lock", component: EmptyComponent },
{ path: "set-password", component: EmptyComponent },
{ path: "set-password-jit", component: EmptyComponent },
{ path: "set-initial-password", component: EmptyComponent },
{ path: "update-temp-password", component: EmptyComponent },
{ path: "change-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent },
]),
],
@@ -69,6 +82,7 @@ describe("AuthGuard", () => {
{ provide: KeyConnectorService, useValue: keyConnectorService },
{ provide: AccountService, useValue: accountService },
{ provide: MasterPasswordServiceAbstraction, useValue: masterPasswordService },
{ provide: ConfigService, useValue: configService },
],
});
@@ -110,70 +124,152 @@ describe("AuthGuard", () => {
expect(router.url).toBe("/remove-password");
});
it("should redirect to set-password when user is TDE user without password and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
describe("given user is Unlocked", () => {
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.SsoNewJitProvisionedUser,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
ForceSetPasswordReason.TdeOffboarding,
];
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-password");
});
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
it("should redirect to update-temp-password when user has force set password reason", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given user attempts to navigate to /set-initial-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
it("should redirect to update-temp-password when user has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
await router.navigate(["/set-initial-password"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
});
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
const tests = [
{
reason: ForceSetPasswordReason.SsoNewJitProvisionedUser,
url: "/set-password-jit",
},
{
reason: ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
url: "/set-password",
},
{
reason: ForceSetPasswordReason.TdeOffboarding,
url: "/update-temp-password",
},
];
it("should allow navigation to set-password when the user is unlocked, is a TDE user without password, and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach(({ reason, url }) => {
it(`should redirect to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/set-password"]);
expect(router.url).toContain("/set-password");
});
await router.navigate(["/guarded-route"]);
expect(router.url).toContain(url);
});
});
});
it("should allow navigation to update-temp-password when the user is unlocked and has admin force password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
describe("given user attempts to navigate to the set- or update- password route itself", () => {
tests.forEach(({ reason, url }) => {
it(`should allow navigation to continue to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
await router.navigate([url]);
expect(router.url).toContain(url);
});
});
});
});
it("should allow navigation to update-temp-password when the user is unlocked and has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
it("should allow navigation to remove-password when the user is unlocked and has 'none' password reset permission", async () => {
const { router } = setup(AuthenticationStatus.Unlocked, ForceSetPasswordReason.None);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/change-password");
});
});
});
await router.navigate(["/remove-password"]);
expect(router.url).toContain("/remove-password");
describe("given user attempts to navigate to /change-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
await router.navigate(["/change-password"]);
expect(router.url).toContain("/change-password");
});
});
});
});
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is OFF", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
describe("given user attempts to navigate to /update-temp-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
});
});
});

View File

@@ -60,15 +60,45 @@ export const authGuard: CanActivateFn = async (
masterPasswordService.forceSetPasswordReason$(userId),
);
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// User JIT provisioned into a master-password-encryption org
if (
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
!routerState.url.includes("set-password-jit") &&
!routerState.url.includes("set-initial-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password-jit";
return router.createUrlTree([route]);
}
// TDE org user has "manage account recovery" permission
if (
forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password")
!routerState.url.includes("set-password") &&
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-password"]);
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password";
return router.createUrlTree([route]);
}
if (await configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)) {
// TDE Offboarding
if (
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding &&
!routerState.url.includes("update-temp-password") &&
!routerState.url.includes("set-initial-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/update-temp-password";
return router.createUrlTree([route]);
}
if (isChangePasswordFlagOn) {
// When the PM16117_ChangeExistingPasswordRefactor flag is removed AS WELL AS the cleanup for
// update-temp-password also remove the conditional check for update-temp-password here.
// That route will no longer be in effect.

View File

@@ -1,6 +1,6 @@
export * from "./auth.guard";
export * from "./active-auth.guard";
export * from "./lock.guard";
export * from "./redirect.guard";
export * from "./redirect/redirect.guard";
export * from "./tde-decryption-required.guard";
export * from "./unauth.guard";

View File

@@ -0,0 +1,53 @@
# Redirect Guard
The `redirectGuard` redirects the user based on their `AuthenticationStatus`. It is applied to the root route (`/`).
<br>
### Order of Operations
The `redirectGuard` will redirect the user based on the following checks, _in order_:
- **`AuthenticationStatus.LoggedOut`** &rarr; redirect to `/login`
- **`AuthenticationStatus.Unlocked`** &rarr; redirect to `/vault`
- **`AuthenticationStatus.Locked`**
- **TDE Locked State** &rarr; redirect to `/login-initiated`
- A user is in a TDE Locked State if they meet all 3 of the following conditions
1. Auth status is `Locked`
2. TDE is enabled
3. User has never had a user key (that is, user has not unlocked/decrypted yet)
- **Standard Locked State** &rarr; redirect to `/lock`
<br>
| Order | AuthenticationStatus | Redirect To |
| ----- | ------------------------------------------------------------------------------- | ------------------ |
| 1 | `LoggedOut` | `/login` |
| 2 | `Unlocked` | `/vault` |
| 3 | **TDE Locked State** <br> `Locked` + <br> `tdeEnabled` + <br> `!everHadUserKey` | `/login-initiated` |
| 4 | **Standard Locked State** <br> `Locked` | `/lock` |
<br>
### Default Routes and Route Overrides
The default redirect routes are mapped to object properties:
```typescript
const defaultRoutes: RedirectRoutes = {
loggedIn: "/vault",
loggedOut: "/login",
locked: "/lock",
notDecrypted: "/login-initiated",
};
```
But when applying the guard to the root route, the developer can override specific redirect routes by passing in a custom object. This is useful for subtle differences in client-specific routing:
```typescript
// app-routing.module.ts (Browser Extension)
{
path: "",
canActivate: [redirectGuard({ loggedIn: "/tabs/current"})],
}
```

View File

@@ -25,12 +25,14 @@ const defaultRoutes: RedirectRoutes = {
};
/**
* Guard that consolidates all redirection logic, should be applied to root route.
* Redirects the user to the appropriate route based on their `AuthenticationStatus`.
* This guard should be applied to the root route.
*
* TODO: This should return Observable<boolean | UrlTree> once we can get rid of all the promises
*/
export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActivateFn {
const routes = { ...defaultRoutes, ...overrides };
return async (route) => {
const authService = inject(AuthService);
const keyService = inject(KeyService);
@@ -41,16 +43,21 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
const authStatus = await authService.getAuthStatus();
// Logged Out
if (authStatus === AuthenticationStatus.LoggedOut) {
return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams });
}
// Unlocked
if (authStatus === AuthenticationStatus.Unlocked) {
return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams });
}
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.
// Locked: TDE Locked State
// - If user meets all 3 of the following conditions:
// 1. Auth status is Locked
// 2. TDE is enabled
// 3. User has never had a user key (has not decrypted yet)
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
@@ -64,6 +71,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams });
}
// Locked: Standard Locked State
if (authStatus === AuthenticationStatus.Locked) {
return router.createUrlTree([routes.locked], { queryParams: route.queryParams });
}

View File

@@ -14,8 +14,6 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
@@ -42,6 +40,7 @@ import {
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
@@ -50,6 +49,7 @@ import {
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
LogoutService,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
@@ -294,10 +294,15 @@ import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
ToastService,
} from "@bitwarden/components";
import {
GeneratorHistoryService,
LocalGeneratorHistoryService,
@@ -405,6 +410,7 @@ const safeProviders: SafeProvider[] = [
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
// TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService
safeProvider({
provide: LOGOUT_CALLBACK,
useFactory:
@@ -676,6 +682,11 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
],
}),
safeProvider({
provide: RestrictedItemTypesService,
useClass: RestrictedItemTypesService,
deps: [ConfigService, AccountService, OrganizationServiceAbstraction, PolicyServiceAbstraction],
}),
safeProvider({
provide: PasswordStrengthServiceAbstraction,
useClass: PasswordStrengthService,
@@ -881,6 +892,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
AccountServiceAbstraction,
ApiServiceAbstraction,
RestrictedItemTypesService,
],
}),
safeProvider({
@@ -896,6 +908,7 @@ const safeProviders: SafeProvider[] = [
CollectionService,
KdfConfigService,
AccountServiceAbstraction,
RestrictedItemTypesService,
],
}),
safeProvider({
@@ -1540,6 +1553,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: LogoutService,
useClass: DefaultLogoutService,
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: DocumentLangSetter,
useClass: DocumentLangSetter,

View File

@@ -84,7 +84,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
showCardNumber = false;
showCardCode = false;
cipherType = CipherType;
typeOptions: any[];
cardBrandOptions: any[];
cardExpMonthOptions: any[];
identityTitleOptions: any[];
@@ -139,13 +138,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
) {
this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
{ name: i18nService.t("typeCard"), value: CipherType.Card },
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
];
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
@@ -215,8 +207,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
ngOnDestroy() {

View File

@@ -8,7 +8,9 @@ import {
combineLatest,
filter,
from,
map,
of,
shareReplay,
switchMap,
takeUntil,
} from "rxjs";
@@ -20,6 +22,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
@Directive()
export class VaultItemsComponent implements OnInit, OnDestroy {
@@ -35,6 +39,19 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
organization: Organization;
CipherType = CipherType;
protected itemTypes$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedItemTypes) =>
// Filter out restricted item types
CIPHER_MENU_ITEMS.filter(
(itemType) =>
!restrictedItemTypes.some(
(restrictedType) => restrictedType.cipherType === itemType.type,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected searchPending = false;
/** Construct filters as an observable so it can be appended to the cipher stream. */
@@ -62,6 +79,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {
this.subscribeToCiphers();
}
@@ -143,18 +161,22 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this._searchText$,
this._filter$,
of(userId),
this.restrictedItemTypesService.restricted$,
]),
),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
let allCiphers = indexedCiphers ?? [];
const _failedCiphers = failedCiphers ?? [];
allCiphers = [..._failedCiphers, ...allCiphers];
const restrictedTypeFilter = (cipher: CipherView) =>
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
return this.searchService.searchCiphers(
userId,
searchText,
[filter, this.deletedFilter],
[filter, this.deletedFilter, restrictedTypeFilter],
allCiphers,
);
}),

View File

@@ -1,14 +1,18 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { catchError, switchMap } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricStateService } from "@bitwarden/key-management";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -21,6 +25,9 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
private logService = inject(LogService);
private pinService = inject(PinServiceAbstraction);
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private biometricStateService = inject(BiometricStateService);
private policyService = inject(PolicyService);
private organizationService = inject(OrganizationService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
@@ -36,16 +43,45 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
from(this.pinService.isPinSet(userId)),
from(this.vaultTimeoutSettingsService.isBiometricLockSet(userId)),
this.biometricStateService.biometricUnlockEnabled$,
this.organizationService.organizations$(userId),
this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId),
]).pipe(
map(([profileCreationDate, status, profileCutoff, isPinSet, isBiometricLockSet]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
const hideNudge = profileOlderThanCutoff || isPinSet || isBiometricLockSet;
return {
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
}),
switchMap(
async ([
profileCreationDate,
status,
profileCutoff,
isPinSet,
biometricUnlockEnabled,
organizations,
policies,
]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
const hasOrgWithRemovePinPolicyOn = organizations.some((org) => {
return policies.some(
(p) => p.type === PolicyType.RemoveUnlockWithPin && p.organizationId === org.id,
);
});
const hideNudge =
profileOlderThanCutoff ||
isPinSet ||
biometricUnlockEnabled ||
hasOrgWithRemovePinPolicyOn;
const acctSecurityNudgeStatus = {
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) {
await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId);
}
return acctSecurityNudgeStatus;
},
),
);
}
}

View File

@@ -6,6 +6,8 @@ import { firstValueFrom, of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -13,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { BiometricStateService } from "@bitwarden/key-management";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec";
@@ -91,6 +94,18 @@ describe("Vault Nudges Service", () => {
provide: VaultTimeoutSettingsService,
useValue: mock<VaultTimeoutSettingsService>(),
},
{
provide: BiometricStateService,
useValue: mock<BiometricStateService>(),
},
{
provide: PolicyService,
useValue: mock<PolicyService>(),
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
},
],
});
});

View File

@@ -159,7 +159,11 @@ export class NudgesService {
*/
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
const nudgeTypes = [
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,
];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
return this.getNudgeService(nudge)