1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00

merge main

This commit is contained in:
rr-bw
2025-11-05 14:06:23 -08:00
110 changed files with 2053 additions and 753 deletions

View File

@@ -187,7 +187,6 @@
"json5",
"keytar",
"libc",
"log",
"lowdb",
"mini-css-extract-plugin",
"napi",
@@ -216,6 +215,8 @@
"simplelog",
"style-loader",
"sysinfo",
"tracing",
"tracing-subscriber",
"ts-node",
"ts-loader",
"tsconfig-paths-webpack-plugin",

View File

@@ -4,6 +4,7 @@ import { componentWrapperDecorator } from "@storybook/angular";
import type { Preview } from "@storybook/angular";
import docJson from "../documentation.json";
setCompodocJson(docJson);
const wrapperDecorator = componentWrapperDecorator((story) => {

View File

@@ -122,10 +122,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
async lock(userId: string) {
this.loading = true;
await this.vaultTimeoutService.lock(userId);
// 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.router.navigate(["lock"]);
await this.lockService.lock(userId as UserId);
await this.router.navigate(["lock"]);
}
async lockAll() {

View File

@@ -25,7 +25,7 @@
<div class="tw-text-sm tw-italic" [attr.aria-hidden]="status.text === 'active'">
<span class="tw-sr-only">(</span>
<span [ngClass]="status.text === 'active' ? 'tw-font-bold tw-text-success' : ''">{{
<span [ngClass]="status.text === 'active' ? 'tw-font-medium tw-text-success' : ''">{{
status.text
}}</span>
<span class="tw-sr-only">)</span>

View File

@@ -6,10 +6,13 @@ import {
MessageListener,
MessageSender,
} from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { newGuid } from "@bitwarden/guid";
import { UserId } from "@bitwarden/user-core";
const LOCK_ALL_FINISHED = new CommandDefinition<{ requestId: string }>("lockAllFinished");
const LOCK_ALL = new CommandDefinition<{ requestId: string }>("lockAll");
const LOCK_USER_FINISHED = new CommandDefinition<{ requestId: string }>("lockUserFinished");
const LOCK_USER = new CommandDefinition<{ requestId: string; userId: UserId }>("lockUser");
export class ForegroundLockService implements LockService {
constructor(
@@ -18,7 +21,7 @@ export class ForegroundLockService implements LockService {
) {}
async lockAll(): Promise<void> {
const requestId = Utils.newGuid();
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_ALL_FINISHED)
@@ -29,4 +32,19 @@ export class ForegroundLockService implements LockService {
await finishMessage;
}
async lock(userId: UserId): Promise<void> {
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_USER_FINISHED)
.pipe(filter((m) => m.requestId === requestId)),
);
this.messageSender.send(LOCK_USER, { requestId, userId });
await finishMessage;
}
async runPlatformOnLockActions(): Promise<void> {}
}

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setPinForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "setYourPinTitle" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -6,6 +6,7 @@ import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { LockService } 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";
@@ -16,7 +17,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutSettingsService,
VaultTimeoutService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -63,6 +63,7 @@ describe("AccountSecurityComponent", () => {
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const lockService = mock<LockService>();
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -83,7 +84,6 @@ describe("AccountSecurityComponent", () => {
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutService, useValue: mock<VaultTimeoutService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{ provide: CipherService, useValue: mock<CipherService>() },
@@ -92,6 +92,7 @@ describe("AccountSecurityComponent", () => {
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
],
})
.overrideComponent(AccountSecurityComponent, {

View File

@@ -25,6 +25,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
import { LockService } from "@bitwarden/auth/common";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
@@ -36,7 +37,6 @@ import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutService,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -143,7 +143,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private vaultTimeoutService: VaultTimeoutService,
private lockService: LockService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
public messagingService: MessagingService,
private environmentService: EnvironmentService,
@@ -695,7 +695,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
}
async lock() {
await this.vaultTimeoutService.lock();
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
}
async logOut() {

View File

@@ -0,0 +1,58 @@
import { DefaultLockService, LogoutService } from "@bitwarden/auth/common";
import MainBackground from "@bitwarden/browser/background/main.background";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { BiometricsService, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { StateEventRunnerService } from "@bitwarden/state";
export class ExtensionLockService extends DefaultLockService {
constructor(
accountService: AccountService,
biometricService: BiometricsService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
logoutService: LogoutService,
messagingService: MessagingService,
searchService: SearchService,
folderService: FolderService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
stateEventRunnerService: StateEventRunnerService,
cipherService: CipherService,
authService: AuthService,
systemService: SystemService,
processReloadService: ProcessReloadServiceAbstraction,
logService: LogService,
keyService: KeyService,
private readonly main: MainBackground,
) {
super(
accountService,
biometricService,
vaultTimeoutSettingsService,
logoutService,
messagingService,
searchService,
folderService,
masterPasswordService,
stateEventRunnerService,
cipherService,
authService,
systemService,
processReloadService,
logService,
keyService,
);
}
async runPlatformOnLockActions(): Promise<void> {
await this.main.refreshMenu(true);
}
}

View File

@@ -1,9 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
// FIXME (PM-22628): Popup imports are forbidden in background
@@ -21,9 +25,10 @@ export default class CommandsBackground {
constructor(
private main: MainBackground,
private platformUtilsService: PlatformUtilsService,
private vaultTimeoutService: VaultTimeoutService,
private authService: AuthService,
private generatePasswordToClipboard: () => Promise<void>,
private accountService: AccountService,
private lockService: LockService,
) {
this.isSafari = this.platformUtilsService.isSafari();
this.isVivaldi = this.platformUtilsService.isVivaldi();
@@ -72,9 +77,11 @@ export default class CommandsBackground {
case "open_popup":
await this.openPopup();
break;
case "lock_vault":
await this.vaultTimeoutService.lock();
case "lock_vault": {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
break;
}
default:
break;
}

View File

@@ -1,6 +1,6 @@
import { firstValueFrom } from "rxjs";
import { LogoutService } from "@bitwarden/auth/common";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutAction,
@@ -23,6 +23,7 @@ export default class IdleBackground {
private serverNotificationsService: ServerNotificationsService,
private accountService: AccountService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private lockService: LockService,
private logoutService: LogoutService,
) {
this.idle = chrome.idle || (browser != null ? browser.idle : null);
@@ -66,7 +67,7 @@ export default class IdleBackground {
if (action === VaultTimeoutAction.LogOut) {
await this.logoutService.logout(userId as UserId, "vaultTimeout");
} else {
await this.vaultTimeoutService.lock(userId);
await this.lockService.lock(userId as UserId);
}
}
}

View File

@@ -20,9 +20,9 @@ import {
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LockService,
LoginEmailServiceAbstraction,
LogoutReason,
UserDecryptionOptionsService,
@@ -270,6 +270,7 @@ import {
import { ExtensionAuthRequestAnsweringService } from "../auth/services/auth-request-answering/extension-auth-request-answering.service";
import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service";
import { ExtensionLockService } from "../auth/services/extension-lock.service";
import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background";
import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background";
import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background";
@@ -363,6 +364,7 @@ export default class MainBackground {
folderService: InternalFolderServiceAbstraction;
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
collectionService: CollectionService;
lockService: LockService;
vaultTimeoutService?: VaultTimeoutService;
vaultTimeoutSettingsService: VaultTimeoutSettingsService;
passwordGenerationService: PasswordGenerationServiceAbstraction;
@@ -496,16 +498,6 @@ export default class MainBackground {
private phishingDataService: PhishingDataService;
constructor() {
// Services
const lockedCallback = async (userId: UserId) => {
await this.refreshMenu(true);
if (this.systemService != null) {
await this.systemService.clearPendingClipboard();
await this.biometricsService.setShouldAutopromptNow(false);
await this.processReloadService.startProcessReload(this.authService);
}
};
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
await this.logout(logoutReason, userId);
@@ -987,27 +979,6 @@ export default class MainBackground {
this.restrictedItemTypesService,
);
const logoutService = new DefaultLogoutService(this.messagingService);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
this.biometricsService,
lockedCallback,
logoutService,
);
this.containerService = new ContainerService(this.keyService, this.encryptService);
this.sendStateProvider = new SendStateProvider(this.stateProvider);
@@ -1271,6 +1242,7 @@ export default class MainBackground {
this.biometricStateService,
this.accountService,
this.logService,
this.authService,
);
// Background
@@ -1284,7 +1256,36 @@ export default class MainBackground {
this.authService,
);
const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService);
const logoutService = new DefaultLogoutService(this.messagingService);
this.lockService = new ExtensionLockService(
this.accountService,
this.biometricsService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
this.systemService,
this.processReloadService,
this.logService,
this.keyService,
this,
);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.platformUtilsService,
this.authService,
this.vaultTimeoutSettingsService,
this.taskSchedulerService,
this.logService,
this.lockService,
logoutService,
);
this.runtimeBackground = new RuntimeBackground(
this,
@@ -1298,7 +1299,7 @@ export default class MainBackground {
this.configService,
messageListener,
this.accountService,
lockService,
this.lockService,
this.billingAccountProfileStateService,
this.browserInitialInstallService,
);
@@ -1318,9 +1319,10 @@ export default class MainBackground {
this.commandsBackground = new CommandsBackground(
this,
this.platformUtilsService,
this.vaultTimeoutService,
this.authService,
() => this.generatePasswordToClipboard(),
this.accountService,
this.lockService,
);
this.taskService = new DefaultTaskService(
@@ -1405,6 +1407,7 @@ export default class MainBackground {
this.serverNotificationsService,
this.accountService,
this.vaultTimeoutSettingsService,
this.lockService,
logoutService,
);
@@ -1752,7 +1755,7 @@ export default class MainBackground {
}
await this.mainContextMenuHandler?.noAccess();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
}
private async needsStorageReseed(userId: UserId): Promise<boolean> {

View File

@@ -257,7 +257,7 @@ export default class RuntimeBackground {
this.lockedVaultPendingNotifications.push(msg.data);
break;
case "lockVault":
await this.main.vaultTimeoutService.lock(msg.userId);
await this.lockService.lock(msg.userId);
break;
case "lockAll":
{
@@ -265,6 +265,14 @@ export default class RuntimeBackground {
this.messagingService.send("lockAllFinished", { requestId: msg.requestId });
}
break;
case "lockUser":
{
await this.lockService.lock(msg.userId);
this.messagingService.send("lockUserFinished", {
requestId: msg.requestId,
});
}
break;
case "logout":
await this.main.logout(msg.expired, msg.userId);
break;

View File

@@ -2,15 +2,10 @@
// @ts-strict-ignore
import { VaultTimeoutService as BaseVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout/abstractions/vault-timeout.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService {
constructor(protected messagingService: MessagingService) {}
// should only ever run in background
async checkVaultTimeout(): Promise<void> {}
async lock(userId?: UserId): Promise<void> {
this.messagingService.send("lockVault", { userId });
}
}

View File

@@ -140,6 +140,11 @@ describe("BrowserPopupUtils", () => {
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({
os: "linux",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
@@ -150,6 +155,8 @@ describe("BrowserPopupUtils", () => {
width: 380,
});
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation();
});
it("creates a window with the default window options", async () => {
@@ -267,6 +274,63 @@ describe("BrowserPopupUtils", () => {
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "mac",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
jest
.spyOn(BrowserApi, "createWindow")
.mockResolvedValueOnce({ id: 2 } as chrome.windows.Window);
await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 });
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, {
state: "maximized",
});
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
focused: true,
});
});
it("doesnt exit fullscreen if the platform is not mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "win",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, {
state: "maximized",
});
});
});
describe("openCurrentPagePopout", () => {

View File

@@ -168,8 +168,29 @@ export default class BrowserPopupUtils {
) {
return;
}
const platform = await BrowserApi.getPlatformInfo();
const isMacOS = platform.os === "mac";
const isFullscreen = senderWindow.state === "fullscreen";
const isFullscreenAndMacOS = isFullscreen && isMacOS;
//macOS specific handling for improved UX when sender in fullscreen aka green button;
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(senderWindow.id, {
state: "maximized",
});
return await BrowserApi.createWindow(popoutWindowOptions);
//wait for macOS animation to finish
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const newWindow = await BrowserApi.createWindow(popoutWindowOptions);
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(newWindow.id, {
focused: true,
});
}
return newWindow;
}
/**

View File

@@ -29,11 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
} from "@bitwarden/components";
import { VaultLoadingSkeletonComponent } from "../../../vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
import { PopupFooterComponent } from "./popup-footer.component";
@@ -366,9 +364,7 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
VaultLoadingSkeletonComponent,
],
providers: [
{
@@ -634,21 +630,9 @@ export const SkeletonLoading: Story = {
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<popup-page>
<popup-page hideOverflow>
<popup-header slot="header" pageTitle="Page Header"></popup-header>
<div>
<div class="tw-sr-only" role="status">Loading...</div>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-1/3"></bit-skeleton-text>
@for (num of data; track $index) {
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
<bit-skeleton class="tw-w-full tw-h-[1px]"></bit-skeleton>
}
</div>
</div>
<vault-loading-skeleton></vault-loading-skeleton>
</popup-page>
</popup-tab-navigation>
</extension-container>

View File

@@ -1,7 +1,7 @@
<ng-content select="[slot=header]"></ng-content>
<main class="tw-flex-1 tw-overflow-hidden tw-flex tw-flex-col tw-relative tw-bg-background-alt">
<ng-content select="[slot=full-width-notice]"></ng-content>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
@@ -10,26 +10,28 @@
#nonScrollable
class="tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]"
[ngClass]="{
'tw-invisible !tw-p-0 !tw-border-none': loading || nonScrollable.childElementCount === 0,
'tw-invisible !tw-p-0 !tw-border-none': loading() || nonScrollable.childElementCount === 0,
'tw-border-secondary-300': scrolled(),
'tw-border-transparent': !scrolled(),
}"
>
<ng-content select="[slot=above-scroll-area]"></ng-content>
</div>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
-->
<div
class="tw-overflow-y-auto tw-size-full tw-styled-scrollbar"
class="tw-size-full tw-styled-scrollbar"
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
'tw-invisible': loading,
'tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),
'tw-invisible': loading(),
'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
!disablePadding,
!disablePadding(),
}"
bitScrollLayoutHost
>
@@ -37,9 +39,9 @@
</div>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-text-main"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !loading() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText"></i>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText()"></i>
</span>
</main>
<ng-content select="[slot=footer]"></ng-content>

View File

@@ -1,11 +1,16 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, inject, Input, signal } from "@angular/core";
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ScrollLayoutHostDirective } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-page",
templateUrl: "popup-page.component.html",
@@ -13,28 +18,23 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components";
class: "tw-h-full tw-flex tw-flex-col tw-overflow-y-hidden",
},
imports: [CommonModule, ScrollLayoutHostDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PopupPageComponent {
protected i18nService = inject(I18nService);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading = false;
readonly loading = input<boolean>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
disablePadding = false;
readonly disablePadding = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected scrolled = signal(false);
/** Hides any overflow within the page content */
readonly hideOverflow = input(false, { transform: booleanAttribute });
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
/** Accessible loading label for the spinner. Defaults to "loading" */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loadingText?: string = this.i18nService.t("loading");
readonly loadingText = input<string | undefined>(this.i18nService.t("loading"));
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);

View File

@@ -0,0 +1,15 @@
<section aria-hidden="true">
<div class="tw-mt-1.5 tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-[8.625rem] tw-max-w-full tw-mb-2.5"></bit-skeleton-text>
@for (num of numberOfItems; track $index) {
<bit-skeleton-group class="tw-mx-2">
<bit-skeleton class="tw-size-6" slot="start"></bit-skeleton>
<div class="tw-flex tw-flex-col tw-gap-1">
<bit-skeleton class="tw-w-40 tw-h-2.5 tw-max-w-full"></bit-skeleton>
<bit-skeleton class="tw-w-24 tw-h-2.5 tw-max-w-full"></bit-skeleton>
</div>
</bit-skeleton-group>
<hr class="tw-h-[1px] -tw-mr-3 tw-bg-secondary-100 tw-border-none" />
}
</div>
</section>

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import {
SkeletonComponent,
SkeletonGroupComponent,
SkeletonTextComponent,
} from "@bitwarden/components";
@Component({
selector: "vault-loading-skeleton",
templateUrl: "./vault-loading-skeleton.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonGroupComponent, SkeletonComponent, SkeletonTextComponent],
})
export class VaultLoadingSkeletonComponent {
protected readonly numberOfItems: null[] = new Array(15).fill(null);
}

View File

@@ -1,16 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
export class LockCommand {
constructor(private vaultTimeoutService: VaultTimeoutService) {}
constructor(
private lockService: LockService,
private accountService: AccountService,
) {}
async run() {
await this.vaultTimeoutService.lock();
process.env.BW_SESSION = null;
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
process.env.BW_SESSION = undefined;
const res = new MessageResponse("Your vault is locked.", null);
return Response.success(res);
}

View File

@@ -0,0 +1,10 @@
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
/**
* CLI implementation of ProcessReloadServiceAbstraction.
* This is NOOP since there is no effective way to process reload the CLI.
*/
export class CliProcessReloadService extends ProcessReloadServiceAbstraction {
async startProcessReload(): Promise<void> {}
async cancelProcessReload(): Promise<void> {}
}

View File

@@ -160,7 +160,10 @@ export class OssServeConfigurator {
this.serviceContainer.cipherService,
this.serviceContainer.accountService,
);
this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService);
this.lockCommand = new LockCommand(
serviceContainer.lockService,
serviceContainer.accountService,
);
this.unlockCommand = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,

View File

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* CLI implementation of SystemService.
* The implementation is NOOP since these functions are meant for GUI clients.
*/
export class CliSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -250,7 +250,10 @@ export class Program extends BaseProgram {
return;
}
const command = new LockCommand(this.serviceContainer.vaultTimeoutService);
const command = new LockCommand(
this.serviceContainer.lockService,
this.serviceContainer.accountService,
);
const response = await command.run();
this.processResponse(response);
});

View File

@@ -20,6 +20,9 @@ import {
SsoUrlService,
AuthRequestApiServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
LockService,
} from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
@@ -199,9 +202,11 @@ import {
} from "@bitwarden/vault-export-core";
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { CliProcessReloadService } from "../key-management/cli-process-reload.service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service";
import { CliSystemService } from "../platform/services/cli-system.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
@@ -318,6 +323,7 @@ export class ServiceContainer {
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
lockService: LockService;
constructor() {
let p = null;
@@ -778,9 +784,6 @@ export class ServiceContainer {
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
const lockedCallback = async (userId: UserId) =>
await this.keyService.clearStoredUserKey(userId);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
this.userVerificationService = new UserVerificationService(
@@ -796,25 +799,35 @@ export class ServiceContainer {
);
const biometricService = new CliBiometricsService();
const logoutService = new DefaultLogoutService(this.messagingService);
const processReloadService = new CliProcessReloadService();
const systemService = new CliSystemService();
this.lockService = new DefaultLockService(
this.accountService,
biometricService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
systemService,
processReloadService,
this.logService,
this.keyService,
);
this.vaultTimeoutService = new DefaultVaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
biometricService,
lockedCallback,
this.lockService,
undefined,
);

View File

@@ -92,18 +92,18 @@ export class CreateCommand {
}
private async createCipher(req: CipherExport) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
const newCipher = await this.cipherService.createWithServer(cipher);
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
const res = new CipherResponse(decCipher);

View File

@@ -21,11 +21,13 @@ platform :mac do
.split('.')
.map(&:strip)
.reject(&:empty?)
.map { |item| "• #{item}" }
.map { |item| "• #{item.gsub(/\A(?:•|\u2022)\s*/, '')}" }
.join("\n")
UI.message("Original changelog: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}")
UI.message("Formatted changelog: #{formatted_changelog[0,100]}#{formatted_changelog.length > 100 ? '...' : ''}")
UI.message("Original changelog: ")
UI.message("#{changelog}")
UI.message("Formatted changelog: ")
UI.message("#{formatted_changelog}")
# Create release notes directories and files for all locales
APP_CONFIG[:locales].each do |locale|

View File

@@ -33,6 +33,7 @@ import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
DESKTOP_SSO_CALLBACK,
LockService,
LogoutReason,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
@@ -199,6 +200,7 @@ export class AppComponent implements OnInit, OnDestroy {
private pinService: PinServiceAbstraction,
private readonly tokenService: TokenService,
private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy,
private readonly lockService: LockService,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestService: AuthRequestServiceAbstraction,
private authRequestAnsweringService: AuthRequestAnsweringService,
@@ -254,7 +256,7 @@ export class AppComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateAppMenu();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "authBlocked":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@@ -267,21 +269,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.loading = false;
break;
case "lockVault":
await this.vaultTimeoutService.lock(message.userId);
await this.lockService.lock(message.userId);
break;
case "lockAllVaults": {
const currentUser = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a.id)),
);
const accounts = await firstValueFrom(this.accountService.accounts$);
await this.vaultTimeoutService.lock(currentUser);
for (const account of Object.keys(accounts)) {
if (account === currentUser) {
continue;
}
await this.vaultTimeoutService.lock(account);
}
await this.lockService.lockAll();
break;
}
case "locked":
@@ -295,12 +286,12 @@ export class AppComponent implements OnInit, OnDestroy {
}
await this.updateAppMenu();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "startProcessReload":
// 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.processReloadService.startProcessReload(this.authService);
this.processReloadService.startProcessReload();
break;
case "cancelProcessReload":
this.processReloadService.cancelProcessReload();
@@ -772,8 +763,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
await this.updateAppMenu();
// This must come last otherwise the logout will prematurely trigger
// a process reload before all the state service user data can be cleaned up
this.authService.logOut(async () => {}, userBeingLoggedOut);
@@ -850,11 +839,9 @@ export class AppComponent implements OnInit, OnDestroy {
}
const options = await this.getVaultTimeoutOptions(userId);
if (options[0] === timeout) {
// 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
options[1] === "logOut"
? this.logOut("vaultTimeout", userId as UserId)
: await this.vaultTimeoutService.lock(userId);
? await this.logOut("vaultTimeout", userId as UserId)
: await this.lockService.lock(userId as UserId);
}
}
}

View File

@@ -266,6 +266,7 @@ const safeProviders: SafeProvider[] = [
BiometricStateService,
AccountServiceAbstraction,
LogService,
AuthServiceAbstraction,
],
}),
safeProvider({

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setPinForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "unlockWithPin" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -69,6 +69,9 @@
}
}
},
"noEditPermissions": {
"message": "You don't have permission to edit this item"
},
"welcomeBack": {
"message": "Welcome back"
},

View File

@@ -124,7 +124,7 @@
>
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>
<p class="tw-font-bold tw-mt-2">
<p class="tw-font-medium tw-mt-2">
{{ "upgradeEventLogTitleMessage" | i18n }}
</p>
<p>

View File

@@ -34,7 +34,7 @@
(change)="toggleAllVisible($event)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
@@ -64,7 +64,7 @@
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" />
</td>
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
<td bitCell class="tw-cursor-pointer tw-font-medium" (click)="edit(g)">
<button type="button" bitLink>
{{ g.details.name }}
</button>

View File

@@ -94,7 +94,7 @@
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>

View File

@@ -7,7 +7,7 @@
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
@@ -19,11 +19,11 @@
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
@@ -31,7 +31,7 @@
</li>
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}

View File

@@ -100,7 +100,7 @@
<ng-template #readOnlyPerm>
<div
*ngIf="item.readonly || disabled"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-medium tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n"
>
{{ permissionLabelId(item.readonlyPermission) | i18n }}

View File

@@ -8,6 +8,7 @@ import { Subject, filter, firstValueFrom, map, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { LockService } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,7 +17,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -58,8 +58,8 @@ export class AppComponent implements OnDestroy, OnInit {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private ngZone: NgZone,
private vaultTimeoutService: VaultTimeoutService,
private keyService: KeyService,
private lockService: LockService,
private collectionService: CollectionService,
private searchService: SearchService,
private serverNotificationsService: ServerNotificationsService,
@@ -113,11 +113,13 @@ export class AppComponent implements OnDestroy, OnInit {
// note: the message.logoutReason isn't consumed anymore because of the process reload clearing any toasts.
await this.logOut(message.redirect);
break;
case "lockVault":
await this.vaultTimeoutService.lock();
case "lockVault": {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.lockService.lock(userId);
break;
}
case "locked":
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "lockedUrl":
break;
@@ -267,7 +269,7 @@ export class AppComponent implements OnDestroy, OnInit {
await this.router.navigate(["/"]);
}
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
// Normally we would need to reset the loading state to false or remove the layout_frontend
// class from the body here, but the process reload completely reloads the app so

View File

@@ -8,6 +8,7 @@ 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 { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => {
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
{ provide: CipherRiskService, useValue: mock<CipherRiskService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
],
})
.overrideComponent(EmergencyViewDialogComponent, {
@@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: ChangeLoginPasswordService,
},
{ provide: ConfigService, useValue: ConfigService },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
@@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: mock<ChangeLoginPasswordService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},

View File

@@ -17,10 +17,10 @@
<ul class="bwi-ul">
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-medium">
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
</span>
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
{{ k.name }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">

View File

@@ -45,7 +45,7 @@
</div>
</div>
</div>
<p bitTypography="body1" class="tw-font-bold tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<p bitTypography="body1" class="tw-font-medium tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<bit-form-control [disableMargin]="true">
<bit-label>{{ "twoFactorYubikeySupportsNfc" | i18n }}</bit-label>
<input bitCheckbox type="checkbox" formControlName="anyKeyHasNfc" />

View File

@@ -53,7 +53,7 @@
<div bit-item-content class="tw-px-4">
<h3 class="tw-mb-0">
<div
class="tw-font-semibold tw-text-base"
class="tw-font-medium tw-text-base"
[style]="p.enabled || p.premium ? 'display:inline-block' : ''"
>
{{ p.name }}

View File

@@ -34,7 +34,7 @@
<table *ngIf="hasCredentials" class="tw-mb-5">
<tr *ngFor="let credential of credentials">
<td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td>
<td class="tw-p-2 tw-pl-0 tw-font-medium">{{ credential.name }}</td>
<td class="tw-p-2 tw-pr-10 tw-text-left">
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
<i class="bwi bwi-lock-encrypted"></i>

View File

@@ -2,15 +2,15 @@ import { inject, NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumVNextComponent } from "./premium/premium-vnext.component";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -26,22 +26,55 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
...componentRouteSwap(
PremiumComponent,
PremiumVNextComponent,
() => {
const configService = inject(ConfigService);
const platformUtilsService = inject(PlatformUtilsService);
/**
* Three-Route Matching Strategy for /premium:
*
* Routes are evaluated in order using canMatch guards. The first route that matches will be selected.
*
* 1. Self-Hosted Environment → SelfHostedPremiumComponent
* - Matches when platformUtilsService.isSelfHost() === true
*
* 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent
* - Only evaluated if Route 1 doesn't match (not self-hosted)
* - Matches when PM24033PremiumUpgradeNewDesign feature flag === true
*
* 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback)
* - No canMatch guard, so this always matches as the fallback route
* - Used when neither Route 1 nor Route 2 match
*/
// Route 1: Self-Hosted -> SelfHostedPremiumComponent
{
path: "premium",
component: SelfHostedPremiumComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const platformUtilsService = inject(PlatformUtilsService);
return platformUtilsService.isSelfHost();
},
],
},
// Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent
{
path: "premium",
component: CloudHostedPremiumVNextComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const configService = inject(ConfigService);
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost()));
},
{
data: { titleId: "goPremium" },
path: "premium",
},
),
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true));
},
],
},
// Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback)
{
path: "premium",
component: CloudHostedPremiumComponent,
data: { titleId: "goPremium" },
},
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -11,7 +11,7 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -28,7 +28,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
SubscriptionComponent,
BillingHistoryViewComponent,
UserSubscriptionComponent,
PremiumComponent,
CloudHostedPremiumComponent,
],
})
export class IndividualBillingModule {}

View File

@@ -7,7 +7,7 @@
</span>
</div>
<h2 *ngIf="!isSelfHost" class="tw-mt-2 tw-text-4xl">
<h2 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">

View File

@@ -21,7 +21,6 @@ import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
BadgeModule,
@@ -52,7 +51,7 @@ const RouteParamValues = {
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./premium-vnext.component.html",
templateUrl: "./cloud-hosted-premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
@@ -64,7 +63,7 @@ const RouteParamValues = {
PricingCardComponent,
],
})
export class PremiumVNextComponent {
export class CloudHostedPremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
@@ -81,22 +80,18 @@ export class PremiumVNextComponent {
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
protected isSelfHost = false;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
@@ -187,10 +182,12 @@ export class PremiumVNextComponent {
this.shouldShowUpgradeDialogOnInit$
.pipe(
switchMap(async (shouldShowUpgradeDialogOnInit) => {
switchMap((shouldShowUpgradeDialogOnInit) => {
if (shouldShowUpgradeDialogOnInit) {
from(this.openUpgradeDialog("Premium"));
return from(this.openUpgradeDialog("Premium"));
}
// Return an Observable that completes immediately when dialog should not be shown
return of(void 0);
}),
takeUntilDestroyed(this.destroyRef),
)

View File

@@ -10,7 +10,7 @@
} @else {
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<h2 bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
@@ -51,7 +51,7 @@
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
<p bitTypography="body1" class="tw-mb-0">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
@@ -65,24 +65,9 @@
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<form [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">

View File

@@ -27,7 +27,6 @@ import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/ser
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
@@ -45,11 +44,11 @@ import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./premium.component.html",
templateUrl: "./cloud-hosted-premium.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
})
export class PremiumComponent {
export class CloudHostedPremiumComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
@@ -121,7 +120,6 @@ export class PremiumComponent {
);
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected readonly familyPlanMaxUserCount = 6;
constructor(
@@ -130,7 +128,6 @@ export class PremiumComponent {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
@@ -139,8 +136,6 @@ export class PremiumComponent {
private taxClient: TaxClient,
private subscriptionPricingService: DefaultSubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
@@ -231,7 +226,10 @@ export class PremiumComponent {
const formData = new FormData();
formData.append("paymentMethodType", paymentMethodType.toString());
formData.append("paymentToken", paymentToken);
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
formData.append(
"additionalStorageGb",
(this.formGroup.value.additionalStorage ?? 0).toString(),
);
formData.append("country", this.formGroup.value.billingAddress.country);
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
@@ -239,12 +237,4 @@ export class PremiumComponent {
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
}

View File

@@ -0,0 +1,49 @@
<bit-container>
<bit-section>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<a
bitButton
href="{{ cloudPremiumPageUrl$ | async }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section>
<individual-self-hosting-license-uploader (onLicenseFileUploaded)="onLicenseFileUploaded()" />
</bit-section>
</bit-container>

View File

@@ -0,0 +1,79 @@
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { BillingSharedModule } from "@bitwarden/web-vault/app/billing/shared";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./self-hosted-premium.component.html",
imports: [SharedModule, BillingSharedModule],
})
export class SelfHostedPremiumComponent {
cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe(
map((url) => `${url}/#/settings/subscription/premium`),
);
hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
onLicenseFileUploaded = async () => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscription();
};
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private router: Router,
private toastService: ToastService,
) {
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(),
switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => {
if (hasPremiumFromAnyOrganization) {
return this.navigateToVault();
}
if (hasPremiumPersonally) {
return this.navigateToSubscription();
}
return of(true);
}),
)
.subscribe();
}
navigateToSubscription = () =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
navigateToVault = () => this.router.navigate(["/vault"]);
}

View File

@@ -7,16 +7,21 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
import { UnifiedUpgradePromptService } from "./unified-upgrade-prompt.service";
import {
UnifiedUpgradePromptService,
PREMIUM_MODAL_DISMISSED_KEY,
} from "./unified-upgrade-prompt.service";
describe("UnifiedUpgradePromptService", () => {
let sut: UnifiedUpgradePromptService;
@@ -29,6 +34,8 @@ describe("UnifiedUpgradePromptService", () => {
const mockOrganizationService = mock<OrganizationService>();
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
const mockPlatformUtilsService = mock<PlatformUtilsService>();
const mockStateProvider = mock<StateProvider>();
const mockLogService = mock<LogService>();
/**
* Creates a mock DialogRef that implements the required properties for testing
@@ -59,6 +66,8 @@ describe("UnifiedUpgradePromptService", () => {
mockDialogService,
mockOrganizationService,
mockPlatformUtilsService,
mockStateProvider,
mockLogService,
);
}
@@ -72,6 +81,7 @@ describe("UnifiedUpgradePromptService", () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockStateProvider.getUserState$.mockReturnValue(of(false));
setupTestService();
});
@@ -82,6 +92,7 @@ describe("UnifiedUpgradePromptService", () => {
describe("displayUpgradePromptConditionally", () => {
beforeEach(() => {
accountSubject.next(mockAccount); // Reset account to mockAccount
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockDialogService);
@@ -90,11 +101,16 @@ describe("UnifiedUpgradePromptService", () => {
mockReset(mockVaultProfileService);
mockReset(mockSyncService);
mockReset(mockOrganizationService);
mockReset(mockStateProvider);
// Mock sync service methods
mockSyncService.fullSync.mockResolvedValue(true);
mockSyncService.lastSync$.mockReturnValue(of(new Date()));
mockReset(mockPlatformUtilsService);
// Default: modal has not been dismissed
mockStateProvider.getUserState$.mockReturnValue(of(false));
mockStateProvider.setUserState.mockResolvedValue(undefined);
});
it("should subscribe to account and feature flag observables when checking display conditions", async () => {
// Arrange
@@ -256,5 +272,71 @@ describe("UnifiedUpgradePromptService", () => {
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has previously dismissed the modal", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockStateProvider.getUserState$.mockReturnValue(of(true)); // User has dismissed
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should save dismissal state when user closes the dialog", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
PREMIUM_MODAL_DISMISSED_KEY,
true,
mockAccount.id,
);
});
it("should not save dismissal state when user upgrades to premium", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.UpgradedToPremium };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).not.toHaveBeenCalled();
});
});
});

View File

@@ -8,16 +8,29 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
// State key for tracking premium modal dismissal
export const PREMIUM_MODAL_DISMISSED_KEY = new UserKeyDefinition<boolean>(
BILLING_DISK,
"premiumModalDismissed",
{
deserializer: (value: boolean) => value,
clearOn: [],
},
);
@Injectable({
providedIn: "root",
})
@@ -32,6 +45,8 @@ export class UnifiedUpgradePromptService {
private dialogService: DialogService,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private stateProvider: StateProvider,
private logService: LogService,
) {}
private shouldShowPrompt$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@@ -45,22 +60,36 @@ export class UnifiedUpgradePromptService {
return of(false);
}
const isProfileLessThanFiveMinutesOld = from(
const isProfileLessThanFiveMinutesOld$ = from(
this.isProfileLessThanFiveMinutesOld(account.id),
);
const hasOrganizations = from(this.hasOrganizations(account.id));
const hasOrganizations$ = from(this.hasOrganizations(account.id));
const hasDismissedModal$ = this.hasDismissedModal$(account.id);
return combineLatest([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
isProfileLessThanFiveMinutesOld$,
hasOrganizations$,
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
hasDismissedModal$,
]).pipe(
map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => {
return (
isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled
);
}),
map(
([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
hasPremium,
isFlagEnabled,
hasDismissed,
]) => {
return (
isProfileLessThanFiveMinutesOld &&
!hasOrganizations &&
!hasPremium &&
isFlagEnabled &&
!hasDismissed
);
},
),
);
}),
take(1),
@@ -114,6 +143,17 @@ export class UnifiedUpgradePromptService {
const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed);
this.unifiedUpgradeDialogRef = null;
// Save dismissal state when the modal is closed without upgrading
if (result?.status === UnifiedUpgradeDialogStatus.Closed) {
try {
await this.stateProvider.setUserState(PREMIUM_MODAL_DISMISSED_KEY, true, account.id);
} catch (error) {
// Log the error but don't block the dialog from closing
// The modal will still close properly even if persistence fails
this.logService.error("Failed to save premium modal dismissal state:", error);
}
}
// Return the result or null if the dialog was dismissed without a result
return result || null;
}
@@ -145,4 +185,15 @@ export class UnifiedUpgradePromptService {
return memberOrganizations.length > 0;
}
/**
* Checks if the user has previously dismissed the premium modal
* @param userId User ID to check
* @returns Observable that emits true if modal was dismissed, false otherwise
*/
private hasDismissedModal$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUserState$(PREMIUM_MODAL_DISMISSED_KEY, userId)
.pipe(map((dismissed) => dismissed ?? false));
}
}

View File

@@ -82,6 +82,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { IpcService } from "@bitwarden/common/platform/ipc";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import {
@@ -145,6 +146,7 @@ import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebSdkLoadService } from "../platform/web-sdk-load.service";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WebSystemService } from "../platform/web-system.service";
import { EventService } from "./event.service";
import { InitService } from "./init.service";
@@ -430,6 +432,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebPremiumInterestStateService,
deps: [StateProvider],
}),
safeProvider({
provide: SystemService,
useClass: WebSystemService,
deps: [],
}),
safeProvider({
provide: AuthRequestAnsweringService,
useClass: NoopAuthRequestAnsweringService,

View File

@@ -1,10 +1,9 @@
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
export class WebProcessReloadService implements ProcessReloadServiceAbstraction {
constructor(private window: Window) {}
async startProcessReload(authService: AuthService): Promise<void> {
async startProcessReload(): Promise<void> {
this.window.location.reload();
}

View File

@@ -50,6 +50,7 @@ import {
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard";
import { flagEnabled, Flags } from "../utils/flags";
@@ -630,7 +631,7 @@ const routes: Routes = [
children: [
{
path: "vault",
canActivate: [setupExtensionRedirectGuard],
canActivate: [premiumInterestRedirectGuard, setupExtensionRedirectGuard],
loadChildren: () => VaultModule,
},
{

View File

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* Web implementation of SystemService.
* The implementation is NOOP since these functions are not supported on web.
*/
export class WebSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -0,0 +1,88 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { premiumInterestRedirectGuard } from "./premium-interest-redirect.guard";
describe("premiumInterestRedirectGuard", () => {
const _state = Object.freeze({}) as RouterStateSnapshot;
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
const account = {
id: "account-id",
} as Account;
const activeAccount$ = new BehaviorSubject<Account | null>(account);
const createUrlTree = jest.fn();
const getPremiumInterest = jest.fn().mockResolvedValue(false);
const logError = jest.fn();
beforeEach(() => {
getPremiumInterest.mockClear();
createUrlTree.mockClear();
logError.mockClear();
activeAccount$.next(account);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: AccountService, useValue: { activeAccount$ } },
{
provide: PremiumInterestStateService,
useValue: { getPremiumInterest },
},
{ provide: LogService, useValue: { error: logError } },
],
});
});
function runPremiumInterestGuard(route?: ActivatedRouteSnapshot) {
// Run the guard within injection context so `inject` works as you'd expect
// Pass state object to make TypeScript happy
return TestBed.runInInjectionContext(async () =>
premiumInterestRedirectGuard(route ?? emptyRoute, _state),
);
}
it("returns `true` when the user does not intend to setup premium", async () => {
getPremiumInterest.mockResolvedValueOnce(false);
expect(await runPremiumInterestGuard()).toBe(true);
});
it("redirects to premium subscription page when user intends to setup premium", async () => {
const urlTree = { toString: () => "/settings/subscription/premium" };
createUrlTree.mockReturnValueOnce(urlTree);
getPremiumInterest.mockResolvedValueOnce(true);
const result = await runPremiumInterestGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/settings/subscription/premium"], {
queryParams: { callToAction: "upgradeToPremium" },
});
expect(result).toBe(urlTree);
});
it("redirects to login when active account is missing", async () => {
const urlTree = { toString: () => "/login" };
createUrlTree.mockReturnValueOnce(urlTree);
activeAccount$.next(null);
const result = await runPremiumInterestGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
expect(result).toBe(urlTree);
});
it("returns `true` and logs error when getPremiumInterest throws an error", async () => {
const error = new Error("Premium interest check failed");
getPremiumInterest.mockRejectedValueOnce(error);
expect(await runPremiumInterestGuard()).toBe(true);
expect(logError).toHaveBeenCalledWith("Error in premiumInterestRedirectGuard", error);
});
});

View File

@@ -0,0 +1,37 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
export const premiumInterestRedirectGuard: CanActivateFn = async () => {
const router = inject(Router);
const accountService = inject(AccountService);
const premiumInterestStateService = inject(PremiumInterestStateService);
const logService = inject(LogService);
try {
const currentAcct = await firstValueFrom(accountService.activeAccount$);
if (!currentAcct) {
return router.createUrlTree(["/login"]);
}
const intendsToSetupPremium = await premiumInterestStateService.getPremiumInterest(
currentAcct.id,
);
if (intendsToSetupPremium) {
return router.createUrlTree(["/settings/subscription/premium"], {
queryParams: { callToAction: "upgradeToPremium" },
});
}
return true;
} catch (error) {
logService.error("Error in premiumInterestRedirectGuard", error);
return true;
}
};

View File

@@ -123,7 +123,7 @@ function displayHandoffMessage(client: string) {
? localeService.t("thisWindowWillCloseIn5Seconds")
: localeService.t("youMayCloseThisWindow");
h1.className = "tw-font-semibold";
h1.className = "tw-font-medium";
p.className = "tw-mb-4";
content.appendChild(h1);

View File

@@ -115,7 +115,7 @@
<button
type="button"
id="webauthn-button"
class="!tw-text-contrast disabled:!tw-text-muted disabled:hover:!tw-text-muted disabled:hover:tw-bg-secondary-300 disabled:hover:tw-border-secondary-300 disabled:hover:tw-no-underline disabled:tw-bg-secondary-300 disabled:tw-border-secondary-300 disabled:tw-cursor-not-allowed focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-block tw-border-2 tw-border-primary-600 tw-border-solid tw-font-semibold tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
class="!tw-text-contrast disabled:!tw-text-muted disabled:hover:!tw-text-muted disabled:hover:tw-bg-secondary-300 disabled:hover:tw-border-secondary-300 disabled:hover:tw-no-underline disabled:tw-bg-secondary-300 disabled:tw-border-secondary-300 disabled:tw-cursor-not-allowed focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-block tw-border-2 tw-border-primary-600 tw-border-solid tw-font-medium tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
></button>
</div>
</div>

View File

@@ -24,7 +24,7 @@
<button
type="button"
id="webauthn-button"
class="tw-cursor-pointer tw-bg-primary-600 tw-border-transparent tw-px-4 tw-py-2 tw-rounded-md hover:tw-bg-primary-700 tw-transition-colors tw-font-semibold tw-text-contrast tw-text-lg"
class="tw-cursor-pointer tw-bg-primary-600 tw-border-transparent tw-px-4 tw-py-2 tw-rounded-md hover:tw-bg-primary-700 tw-transition-colors tw-font-medium tw-text-contrast tw-text-lg"
></button>
</div>
</div>

View File

@@ -9,7 +9,7 @@
<button
type="button"
id="webauthn-button"
class="tw-block !tw-text-contrast focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-border-2 tw-border-primary-600 tw-border-solid tw-font-semibold tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
class="tw-block !tw-text-contrast focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-border-2 tw-border-primary-600 tw-border-solid tw-font-medium tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
></button>
</body>
</html>

View File

@@ -23,6 +23,9 @@
"passwordRisk": {
"message": "Password Risk"
},
"noEditPermissions": {
"message": "You don't have permission to edit this item"
},
"reviewAtRiskPasswords": {
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
},

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "newClientOrganization" | i18n }}
</span>
<div bitDialogContent>
@@ -22,16 +22,16 @@
<div class="tw-relative">
<div
*ngIf="planCard.selected"
class="tw-bg-primary-600 tw-text-center !tw-text-contrast tw-text-sm tw-font-bold tw-py-1 group-hover/plan-card-container:tw-bg-primary-700"
class="tw-bg-primary-600 tw-text-center !tw-text-contrast tw-text-sm tw-font-medium tw-py-1 group-hover/plan-card-container:tw-bg-primary-700"
>
{{ "selected" | i18n }}
</div>
<div class="tw-pl-5 tw-py-4 tw-pr-4" [ngClass]="{ 'tw-pt-10': !planCard.selected }">
<h3 class="tw-text-2xl tw-font-bold tw-uppercase">{{ planCard.name }}</h3>
<span class="tw-text-2xl tw-font-semibold">{{
<h3 class="tw-text-2xl tw-font-medium tw-uppercase">{{ planCard.name }}</h3>
<span class="tw-text-2xl tw-font-medium">{{
planCard.getMonthlyCost() | currency: "$"
}}</span>
<span class="tw-text-sm tw-font-bold"
<span class="tw-text-sm tw-font-medium"
>/ {{ planCard.getTimePerMemberLabel() | i18n }}</span
>
</div>

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "updateName" | i18n }}
<small class="tw-text-muted">{{ dialogParams.organization.name }}</small>
</span>

View File

@@ -18,7 +18,7 @@
<div *ngIf="!loading && !authed">
<p bitTypography="body1" class="tw-text-center">
{{ providerName }}
<span bitTypography="body1" class="tw-font-bold">{{ email }}</span>
<span bitTypography="body1" class="tw-font-medium">{{ email }}</span>
</p>
<p bitTypography="body1">{{ "joinProviderDesc" | i18n }}</p>
<hr />

View File

@@ -67,7 +67,7 @@
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">
{{ "all" | i18n }}
</label>
</th>

View File

@@ -22,7 +22,7 @@
<span slot="secondary" class="tw-text-sm">
<br />
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span class="tw-font-medium"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</span>
@@ -52,7 +52,7 @@
}
<div>
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
<span class="tw-font-medium">{{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</div>

View File

@@ -38,7 +38,7 @@
<div bitTypography="body2">
{{ "accessing" | i18n }}:
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
<b class="tw-text-primary-600 tw-font-semibold">{{
<b class="tw-text-primary-600 tw-font-medium">{{
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
}}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>

View File

@@ -35,9 +35,6 @@ export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SE
export const LOGOUT_CALLBACK = new SafeInjectionToken<
(logoutReason: LogoutReason, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
"LOCKED_CALLBACK",
);
export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS_SECURE_STORAGE");
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");

View File

@@ -40,9 +40,11 @@ import {
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LockService,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
@@ -161,6 +163,7 @@ import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/ser
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import {
DefaultKeyGenerationService,
KeyGenerationService,
@@ -219,6 +222,7 @@ import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sd
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import { UnsupportedActionsService } from "@bitwarden/common/platform/actions/unsupported-actions.service";
@@ -282,6 +286,7 @@ import {
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -303,6 +308,7 @@ import {
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
import { DefaultCipherRiskService } from "@bitwarden/common/vault/services/default-cipher-risk.service";
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";
@@ -399,7 +405,6 @@ import {
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
OBSERVABLE_DISK_STORAGE,
@@ -455,10 +460,6 @@ const safeProviders: SafeProvider[] = [
},
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: LOCKED_CALLBACK,
useValue: null,
}),
safeProvider({
provide: LOG_MAC_FAILURES,
useValue: true,
@@ -603,6 +604,11 @@ const safeProviders: SafeProvider[] = [
MessagingServiceAbstraction,
],
}),
safeProvider({
provide: CipherRiskService,
useClass: DefaultCipherRiskService,
deps: [SdkService, CipherServiceAbstraction],
}),
safeProvider({
provide: InternalFolderService,
useClass: FolderService,
@@ -880,22 +886,12 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultVaultTimeoutService,
deps: [
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
CipherServiceAbstraction,
FolderServiceAbstraction,
CollectionService,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
TokenServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsService,
StateEventRunnerService,
TaskSchedulerService,
LogService,
BiometricsService,
LOCKED_CALLBACK,
LockService,
LogoutService,
],
}),
@@ -1710,6 +1706,27 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
],
}),
safeProvider({
provide: LockService,
useClass: DefaultLockService,
deps: [
AccountService,
BiometricsService,
VaultTimeoutSettingsService,
LogoutService,
MessagingServiceAbstraction,
SearchServiceAbstraction,
FolderServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
StateEventRunnerService,
CipherServiceAbstraction,
AuthServiceAbstraction,
SystemService,
ProcessReloadServiceAbstraction,
LogService,
KeyService,
],
}),
safeProvider({
provide: CipherArchiveService,
useClass: DefaultCipherArchiveService,

View File

@@ -69,7 +69,7 @@
[(toggled)]="showPassword"
></button>
<bit-hint *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
<span class="tw-font-bold">{{ "important" | i18n }} </span>
<span class="tw-font-medium">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordLengthMsg }}.
</bit-hint>

View File

@@ -23,7 +23,7 @@
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<div class="tw-font-medium">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<button
@@ -50,7 +50,7 @@
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<div class="tw-font-medium">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div class="tw-mt-4">

View File

@@ -81,7 +81,7 @@
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
<p bitTypography="body1" class="tw-text-center tw-mb-3 tw-text-main" id="follow_the_link_body">
{{ "followTheLinkInTheEmailSentTo" | i18n }}
<span class="tw-font-bold">{{ email.value }}</span>
<span class="tw-font-medium">{{ email.value }}</span>
{{ "andContinueCreatingYourAccount" | i18n }}
</p>

View File

@@ -44,7 +44,7 @@
<div class="tw-size-16 tw-content-center tw-mb-4">
<bit-icon [icon]="Icons.UserVerificationBiometricsIcon"></bit-icon>
</div>
<p class="tw-font-bold tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
<p class="tw-font-medium tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
<div *ngIf="!biometricsVerificationFailed">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "awaitingConfirmation" | i18n }}

View File

@@ -1,20 +1,55 @@
import { combineLatest, firstValueFrom, map } from "rxjs";
import { combineLatest, filter, firstValueFrom, map, timeout } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { 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 { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { BiometricsService, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { StateEventRunnerService } from "@bitwarden/state";
import { LogoutService } from "../../abstractions";
export abstract class LockService {
/**
* Locks all accounts.
*/
abstract lockAll(): Promise<void>;
/**
* Performs lock for a user.
* @param userId The user id to lock
*/
abstract lock(userId: UserId): Promise<void>;
abstract runPlatformOnLockActions(): Promise<void>;
}
export class DefaultLockService implements LockService {
constructor(
private readonly accountService: AccountService,
private readonly vaultTimeoutService: VaultTimeoutService,
private readonly biometricService: BiometricsService,
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private readonly logoutService: LogoutService,
private readonly messagingService: MessagingService,
private readonly searchService: SearchService,
private readonly folderService: FolderService,
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
private readonly stateEventRunnerService: StateEventRunnerService,
private readonly cipherService: CipherService,
private readonly authService: AuthService,
private readonly systemService: SystemService,
private readonly processReloadService: ProcessReloadServiceAbstraction,
private readonly logService: LogService,
private readonly keyService: KeyService,
) {}
async lockAll() {
@@ -36,14 +71,88 @@ export class DefaultLockService implements LockService {
);
for (const otherAccount of accounts.otherAccounts) {
await this.vaultTimeoutService.lock(otherAccount);
await this.lock(otherAccount);
}
// Do the active account last in case we ever try to route the user on lock
// that way this whole operation will be complete before that routing
// could take place.
if (accounts.activeAccount != null) {
await this.vaultTimeoutService.lock(accounts.activeAccount);
await this.lock(accounts.activeAccount);
}
}
async lock(userId: UserId): Promise<void> {
assertNonNullish(userId, "userId", "LockService");
this.logService.info(`[LockService] Locking user ${userId}`);
// If user already logged out, then skip locking
if (
(await firstValueFrom(this.authService.authStatusFor$(userId))) ===
AuthenticationStatus.LoggedOut
) {
return;
}
// If user cannot lock, then logout instead
if (!(await this.vaultTimeoutSettingsService.canLock(userId))) {
// Logout should perform the same steps
await this.logoutService.logout(userId, "vaultTimeout");
this.logService.info(`[LockService] User ${userId} cannot lock, logging out instead.`);
return;
}
await this.wipeDecryptedState(userId);
await this.waitForLockedStatus(userId);
await this.systemService.clearPendingClipboard();
await this.runPlatformOnLockActions();
this.logService.info(`[LockService] Locked user ${userId}`);
// Subscribers navigate the client to the lock screen based on this lock message.
// We need to disable auto-prompting as we are just entering a locked state now.
await this.biometricService.setShouldAutopromptNow(false);
this.messagingService.send("locked", { userId });
// Wipe the current process to clear active secrets in memory.
await this.processReloadService.startProcessReload();
}
private async wipeDecryptedState(userId: UserId) {
// Manually clear state
await this.searchService.clearIndex(userId);
//! DO NOT REMOVE folderService.clearDecryptedFolderState ! For more information see PM-25660
await this.folderService.clearDecryptedFolderState(userId);
await this.masterPasswordService.clearMasterKey(userId);
await this.cipherService.clearCache(userId);
// Clear CLI unlock state
await this.keyService.clearStoredUserKey(userId);
// This will clear ephemeral state such as the user's user key based on the key definition's clear-on
await this.stateEventRunnerService.handleEvent("lock", userId);
}
private async waitForLockedStatus(userId: UserId): Promise<void> {
// HACK: Start listening for the transition of the locking user from something to the locked state.
// This is very much a hack to ensure that the authentication status to retrievable right after
// it does its work. Particularly and `"locked"` message. Instead the message should be deprecated
// and people should subscribe and react to `authStatusFor$` themselves.
await firstValueFrom(
this.authService.authStatusFor$(userId).pipe(
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
timeout({
first: 5_000,
with: () => {
throw new Error("The lock process did not complete in a reasonable amount of time.");
},
}),
),
);
}
async runPlatformOnLockActions(): Promise<void> {
// No platform specific actions to run for this platform.
return;
}
}

View File

@@ -1,8 +1,23 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { 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 { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { BiometricsService, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { StateEventRunnerService } from "@bitwarden/state";
import { LogoutService } from "../../abstractions";
import { DefaultLockService } from "./lock.service";
@@ -12,10 +27,57 @@ describe("DefaultLockService", () => {
const mockUser3 = "user3" as UserId;
const accountService = mockAccountServiceWith(mockUser1);
const vaultTimeoutService = mock<VaultTimeoutService>();
const biometricsService = mock<BiometricsService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const logoutService = mock<LogoutService>();
const messagingService = mock<MessagingService>();
const searchService = mock<SearchService>();
const folderService = mock<FolderService>();
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
const stateEventRunnerService = mock<StateEventRunnerService>();
const cipherService = mock<CipherService>();
const authService = mock<AuthService>();
const systemService = mock<SystemService>();
const processReloadService = mock<ProcessReloadServiceAbstraction>();
const logService = mock<LogService>();
const keyService = mock<KeyService>();
const sut = new DefaultLockService(
accountService,
biometricsService,
vaultTimeoutSettingsService,
logoutService,
messagingService,
searchService,
folderService,
masterPasswordService,
stateEventRunnerService,
cipherService,
authService,
systemService,
processReloadService,
logService,
keyService,
);
const sut = new DefaultLockService(accountService, vaultTimeoutService);
describe("lockAll", () => {
const sut = new DefaultLockService(
accountService,
biometricsService,
vaultTimeoutSettingsService,
logoutService,
messagingService,
searchService,
folderService,
masterPasswordService,
stateEventRunnerService,
cipherService,
authService,
systemService,
processReloadService,
logService,
keyService,
);
it("locks the active account last", async () => {
await accountService.addAccount(mockUser2, {
name: "name2",
@@ -25,19 +87,49 @@ describe("DefaultLockService", () => {
await accountService.addAccount(mockUser3, {
name: "name3",
email: "email3@example.com",
email: "name3@example.com",
emailVerified: false,
});
const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined);
await sut.lockAll();
expect(vaultTimeoutService.lock).toHaveBeenCalledTimes(3);
// Non-Active users should be called first
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(1, mockUser2);
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(2, mockUser3);
expect(lockSpy).toHaveBeenNthCalledWith(1, mockUser2);
expect(lockSpy).toHaveBeenNthCalledWith(2, mockUser3);
// Active user should be called last
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(3, mockUser1);
expect(lockSpy).toHaveBeenNthCalledWith(3, mockUser1);
});
});
describe("lock", () => {
const userId = mockUser1;
it("returns early if user is already logged out", async () => {
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.LoggedOut));
await sut.lock(userId);
// Should return early, not call logoutService.logout
expect(logoutService.logout).not.toHaveBeenCalled();
expect(stateEventRunnerService.handleEvent).not.toHaveBeenCalled();
});
it("logs out if user cannot lock", async () => {
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
vaultTimeoutSettingsService.canLock.mockResolvedValue(false);
await sut.lock(userId);
expect(logoutService.logout).toHaveBeenCalledWith(userId, "vaultTimeout");
expect(stateEventRunnerService.handleEvent).not.toHaveBeenCalled();
});
it("locks user", async () => {
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
logoutService.logout.mockClear();
vaultTimeoutSettingsService.canLock.mockResolvedValue(true);
await sut.lock(userId);
expect(logoutService.logout).not.toHaveBeenCalled();
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", userId);
});
});
});

View File

@@ -58,6 +58,7 @@ export enum FeatureFlag {
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
/* Auth */
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,

View File

@@ -1,6 +1,4 @@
import { AuthService } from "../../auth/abstractions/auth.service";
export abstract class ProcessReloadServiceAbstraction {
abstract startProcessReload(authService: AuthService): Promise<void>;
abstract startProcessReload(): Promise<void>;
abstract cancelProcessReload(): void;
}

View File

@@ -30,16 +30,17 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
private biometricStateService: BiometricStateService,
private accountService: AccountService,
private logService: LogService,
private authService: AuthService,
) {}
async startProcessReload(authService: AuthService): Promise<void> {
async startProcessReload(): Promise<void> {
const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) {
const keys = Object.keys(accounts);
if (keys.length > 0) {
for (const userId of keys) {
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
status = await authService.getAuthStatus(userId);
let status = await firstValueFrom(this.authService.authStatusFor$(userId as UserId));
status = await this.authService.getAuthStatus(userId);
if (status === AuthenticationStatus.Unlocked) {
this.logService.info(
"[Process Reload Service] User unlocked, preventing process reload",

View File

@@ -1,4 +1,3 @@
export abstract class VaultTimeoutService {
abstract checkVaultTimeout(): Promise<void>;
abstract lock(userId?: string): Promise<void>;
}

View File

@@ -5,31 +5,17 @@ import { BehaviorSubject, from, of } 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";
// 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 { LogoutService } from "@bitwarden/auth/common";
// 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 { BiometricsService } from "@bitwarden/key-management";
import { StateService } from "@bitwarden/state";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { Utils } from "../../../platform/misc/utils";
import { TaskSchedulerService } from "../../../platform/scheduling";
import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
@@ -38,23 +24,13 @@ import { VaultTimeoutService } from "./vault-timeout.service";
describe("VaultTimeoutService", () => {
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let cipherService: MockProxy<CipherService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let searchService: MockProxy<SearchService>;
let stateService: MockProxy<StateService>;
let tokenService: MockProxy<TokenService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let logService: MockProxy<LogService>;
let biometricsService: MockProxy<BiometricsService>;
let lockService: MockProxy<LockService>;
let logoutService: MockProxy<LogoutService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
@@ -65,25 +41,14 @@ describe("VaultTimeoutService", () => {
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
masterPasswordService = new FakeMasterPasswordService();
cipherService = mock();
folderService = mock();
collectionService = mock();
platformUtilsService = mock();
messagingService = mock();
searchService = mock();
stateService = mock();
tokenService = mock();
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
taskSchedulerService = mock<TaskSchedulerService>();
lockService = mock<LockService>();
logService = mock<LogService>();
biometricsService = mock<BiometricsService>();
logoutService = mock<LogoutService>();
lockedCallback = jest.fn();
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
@@ -94,22 +59,12 @@ describe("VaultTimeoutService", () => {
vaultTimeoutService = new VaultTimeoutService(
accountService,
masterPasswordService,
cipherService,
folderService,
collectionService,
platformUtilsService,
messagingService,
searchService,
stateService,
tokenService,
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
taskSchedulerService,
logService,
biometricsService,
lockedCallback,
lockService,
logoutService,
);
});
@@ -145,9 +100,6 @@ describe("VaultTimeoutService", () => {
authService.getAuthStatus.mockImplementation((userId) => {
return Promise.resolve(accounts[userId]?.authStatus);
});
tokenService.hasAccessToken$.mockImplementation((userId) => {
return of(accounts[userId]?.isAuthenticated ?? false);
});
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
return new BehaviorSubject<VaultTimeout>(accounts[userId]?.vaultTimeout);
@@ -203,13 +155,7 @@ describe("VaultTimeoutService", () => {
};
const expectUserToHaveLocked = (userId: string) => {
// This does NOT assert all the things that the lock process does
expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId);
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);
expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
expect(lockedCallback).toHaveBeenCalledWith(userId);
expect(lockService.lock).toHaveBeenCalledWith(userId);
};
const expectUserToHaveLoggedOut = (userId: string) => {
@@ -217,7 +163,7 @@ describe("VaultTimeoutService", () => {
};
const expectNoAction = (userId: string) => {
expect(lockedCallback).not.toHaveBeenCalledWith(userId);
expect(lockService.lock).not.toHaveBeenCalledWith(userId);
expect(logoutService.logout).not.toHaveBeenCalledWith(userId, "vaultTimeout");
};
@@ -347,12 +293,8 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
expectUserToHaveLocked("2");
// Active users should have additional steps ran
expect(searchService.clearIndex).toHaveBeenCalled();
expect(folderService.clearDecryptedFolderState).toHaveBeenCalled();
expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out
expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout
expectUserToHaveLocked("4"); // They don't have lock available. But this is handled in lock service so we do not check for logout here
});
it("should lock an account if they haven't been active passed their vault timeout even if a view is open when they are not the active user.", async () => {
@@ -392,70 +334,4 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
});
});
describe("lock", () => {
const setupLock = () => {
setupAccounts(
{
user1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
user2: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
},
{
userId: "user1",
},
);
};
it("should call state event runner with currently active user if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call locked callback with the locking user if no userID is passed in.", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(lockedCallback).toHaveBeenCalledWith("user1");
});
it("should call state event runner with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
});
it("should call messaging service locked message with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
});
it("should call locked callback with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(lockedCallback).toHaveBeenCalledWith(user2);
});
});
});

View File

@@ -1,32 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
import { combineLatest, concatMap, firstValueFrom } 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";
// 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 { LogoutService } from "@bitwarden/auth/common";
// 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 { BiometricsService } from "@bitwarden/key-management";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { TaskSchedulerService, ScheduledTaskNames } from "../../../platform/scheduling";
import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
@@ -36,22 +22,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
constructor(
private accountService: AccountService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private cipherService: CipherService,
private folderService: FolderService,
private collectionService: CollectionService,
protected platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private searchService: SearchService,
private stateService: StateService,
private tokenService: TokenService,
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
private lockedCallback: (userId: UserId) => Promise<void> = null,
private lockService: LockService,
private logoutService: LogoutService,
) {
this.taskSchedulerService.registerTaskHandler(
@@ -104,64 +80,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
}
async lock(userId?: UserId): Promise<void> {
await this.biometricService.setShouldAutopromptNow(false);
const lockingUserId =
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId));
if (!authed) {
return;
}
const availableActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
);
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
await this.logoutService.logout(userId, "vaultTimeout");
}
// HACK: Start listening for the transition of the locking user from something to the locked state.
// This is very much a hack to ensure that the authentication status to retrievable right after
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
const lockPromise = firstValueFrom(
this.authService.authStatusFor$(lockingUserId).pipe(
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
timeout({
first: 5_000,
with: () => {
throw new Error("The lock process did not complete in a reasonable amount of time.");
},
}),
),
);
await this.searchService.clearIndex(lockingUserId);
await this.folderService.clearDecryptedFolderState(lockingUserId);
await this.masterPasswordService.clearMasterKey(lockingUserId);
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
await this.cipherService.clearCache(lockingUserId);
await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
// HACK: Sit here and wait for the the auth status to transition to `Locked`
// to ensure the message and lockedCallback will get the correct status
// if/when they call it.
await lockPromise;
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) {
await this.lockedCallback(lockingUserId);
}
}
private async shouldLock(
userId: string,
lastActive: Date,
@@ -206,6 +124,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
timeoutAction === VaultTimeoutAction.LogOut
? await this.logoutService.logout(userId, "vaultTimeout")
: await this.lock(userId);
: await this.lockService.lock(userId);
}
}

View File

@@ -3,6 +3,8 @@ import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.ex
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SshKeyExport } from "./ssh-key.export";
describe("Cipher Export", () => {
describe("toView", () => {
it.each([[null], [undefined]])(
@@ -41,4 +43,36 @@ describe("Cipher Export", () => {
expect(resultView.deletedDate).toEqual(request.deletedDate);
});
});
describe("SshKeyExport.toView", () => {
const validSshKey = {
privateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
keyFingerprint: "FINGERPRINT",
};
it.each([null, undefined, "", " "])("should throw when privateKey is %p", (value) => {
const sshKey = { ...validSshKey, privateKey: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key private key is required.");
});
it.each([null, undefined, "", " "])("should throw when publicKey is %p", (value) => {
const sshKey = { ...validSshKey, publicKey: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key public key is required.");
});
it.each([null, undefined, "", " "])("should throw when keyFingerprint is %p", (value) => {
const sshKey = { ...validSshKey, keyFingerprint: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key fingerprint is required.");
});
it("should succeed with valid inputs", () => {
const sshKey = { ...validSshKey };
const result = SshKeyExport.toView(sshKey);
expect(result).toBeDefined();
expect(result?.privateKey).toBe(validSshKey.privateKey);
expect(result?.publicKey).toBe(validSshKey.publicKey);
expect(result?.keyFingerprint).toBe(validSshKey.keyFingerprint);
});
});
});

View File

@@ -16,7 +16,22 @@ export class SshKeyExport {
return req;
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
static toView(req?: SshKeyExport, view = new SshKeyView()): SshKeyView | undefined {
if (req == null) {
return undefined;
}
// Validate required fields
if (!req.privateKey || req.privateKey.trim() === "") {
throw new Error("SSH key private key is required.");
}
if (!req.publicKey || req.publicKey.trim() === "") {
throw new Error("SSH key public key is required.");
}
if (!req.keyFingerprint || req.keyFingerprint.trim() === "") {
throw new Error("SSH key fingerprint is required.");
}
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;

View File

@@ -0,0 +1,88 @@
import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal";
import { isPasswordAtRisk } from "./cipher-risk.service";
describe("isPasswordAtRisk", () => {
const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId;
const createRisk = (overrides: Partial<CipherRiskResult> = {}): CipherRiskResult => ({
id: mockId,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
...overrides,
});
describe("exposed password risk", () => {
it.each([
{ value: 5, expected: true, desc: "found with value > 0" },
{ value: 0, expected: false, desc: "found but value is 0" },
])("should return $expected when password is $desc", ({ value, expected }) => {
const risk = createRisk({ exposed_result: { type: "Found", value } });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
it("should return false when password is not checked", () => {
expect(isPasswordAtRisk(createRisk())).toBe(false);
});
});
describe("password reuse risk", () => {
it.each([
{ count: 2, expected: true, desc: "reused (reuse_count > 1)" },
{ count: 1, expected: false, desc: "not reused" },
{ count: undefined, expected: false, desc: "undefined" },
])("should return $expected when reuse_count is $desc", ({ count, expected }) => {
const risk = createRisk({ reuse_count: count });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
describe("password strength risk", () => {
it.each([
{ strength: 0, expected: true },
{ strength: 1, expected: true },
{ strength: 2, expected: true },
{ strength: 3, expected: false },
{ strength: 4, expected: false },
])("should return $expected when password strength is $strength", ({ strength, expected }) => {
const risk = createRisk({ password_strength: strength });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
describe("multiple risk factors", () => {
it.each<{ desc: string; overrides: Partial<CipherRiskResult>; expected: boolean }>([
{
desc: "exposed and reused",
overrides: {
exposed_result: { type: "Found" as const, value: 3 },
reuse_count: 2,
},
expected: true,
},
{
desc: "reused and weak strength",
overrides: { password_strength: 2, reuse_count: 2 },
expected: true,
},
{
desc: "all three risk factors",
overrides: {
password_strength: 1,
exposed_result: { type: "Found" as const, value: 10 },
reuse_count: 3,
},
expected: true,
},
{
desc: "no risk factors",
overrides: { reuse_count: undefined },
expected: false,
},
])("should return $expected when $desc present", ({ overrides, expected }) => {
const risk = createRisk(overrides);
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
});

View File

@@ -1,12 +1,10 @@
import type {
CipherRiskResult,
CipherRiskOptions,
ExposedPasswordResult,
PasswordReuseMap,
CipherId,
} from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
export abstract class CipherRiskService {
@@ -51,5 +49,21 @@ export abstract class CipherRiskService {
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
}
// Re-export SDK types for convenience
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
/**
* Evaluates if a password represented by a CipherRiskResult is considered at risk.
*
* A password is considered at risk if any of the following conditions are true:
* - The password has been exposed in data breaches
* - The password is reused across multiple ciphers
* - The password has weak strength (password_strength < 3)
*
* @param risk - The CipherRiskResult to evaluate
* @returns true if the password is at risk, false otherwise
*/
export function isPasswordAtRisk(risk: CipherRiskResult): boolean {
return (
(risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) ||
(risk.reuse_count ?? 1) > 1 ||
risk.password_strength < 3
);
}

View File

@@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata {
return this.passwordHistory && this.passwordHistory.length > 0;
}
get hasLoginPassword(): boolean {
return (
this.type === CipherType.Login && this.login?.password != null && this.login.password !== ""
);
}
get hasAttachments(): boolean {
return !!this.attachments && this.attachments.length > 0;
}

View File

@@ -1,11 +1,11 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal";
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherService } from "../abstractions/cipher.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
@@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => {
let mockCipherService: jest.Mocked<CipherService>;
const mockUserId = "test-user-id" as UserId;
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId;
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId;
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId;
beforeEach(() => {
sdkService = new MockSdkService();
@@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => {
// Verify password_reuse_map was called twice (fresh computation each time)
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
});
it("should wait for a decrypted vault before computing risk", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "password1";
// Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers
const cipherViewsSubject = new BehaviorSubject<CipherView[] | null>(null);
mockCipherService.cipherViews$.mockReturnValue(
cipherViewsSubject as Observable<CipherView[]>,
);
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
// Initiate the async call but don't await yet
const computePromise = cipherRiskService.computeCipherRiskForUser(
asUuid<CipherId>(mockCipherId1),
mockUserId,
true,
);
// Simulate a tick to allow the service to process the null emission
await new Promise((resolve) => setTimeout(resolve, 0));
// Now emit the actual decrypted ciphers
cipherViewsSubject.next([cipher]);
const result = await computePromise;
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[expect.objectContaining({ password: "password1" })],
{
passwordMap: expect.any(Object),
checkExposed: true,
},
);
expect(result).toEqual(expect.objectContaining({ id: expect.anything() }));
});
});
});

View File

@@ -1,16 +1,17 @@
import { firstValueFrom, switchMap } from "rxjs";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
CipherLoginDetails,
CipherRiskOptions,
PasswordReuseMap,
CipherId,
CipherRiskResult,
CipherId as SdkCipherId,
} from "@bitwarden/sdk-internal";
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
@@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
checkExposed: boolean = true,
): Promise<CipherRiskResult> {
// Get all ciphers for the user
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
const allCiphers = await firstValueFrom(
this.cipherService.cipherViews$(userId).pipe(filterOutNullish()),
);
// Find the specific cipher
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
@@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
.map(
(cipher) =>
({
id: asUuid<CipherId>(cipher.id),
id: asUuid<SdkCipherId>(cipher.id),
password: cipher.login.password!,
username: cipher.login.username,
}) satisfies CipherLoginDetails,

View File

@@ -46,3 +46,4 @@ export * from "./tooltip";
export * from "./typography";
export * from "./utils";
export * from "./stepper";
export * from "./switch";

View File

@@ -66,8 +66,6 @@ export default {
type: "figma",
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4",
},
// remove disableSnapshots in CL-890
chromatic: { viewports: [640, 1280], disableSnapshot: true },
},
} as Meta;

View File

@@ -42,8 +42,7 @@ export default {
type: "figma",
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4",
},
// remove disableSnapshots in CL-890
chromatic: { viewports: [640, 1280], disableSnapshot: true },
chromatic: { delay: 1000 },
},
} as Meta;
@@ -136,3 +135,28 @@ export const ForceActiveStyles: Story = {
`,
}),
};
export const CollapsedNavItems: Story = {
render: (args) => ({
props: args,
template: `
<bit-nav-item text="First Nav" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Active Nav" icon="bwi-collection-shared" [forceActiveStyles]="true"></bit-nav-item>
<bit-nav-item text="Third Nav" icon="bwi-collection-shared"></bit-nav-item>
`,
}),
play: async () => {
const toggleButton = document.querySelector(
"[aria-label='Toggle side navigation']",
) as HTMLButtonElement;
if (toggleButton) {
toggleButton.click();
}
},
parameters: {
chromatic: {
delay: 1000,
},
},
};

View File

@@ -1,6 +1,6 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, input, viewChild } from "@angular/core";
import { Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -22,8 +22,7 @@ export class SideNavComponent {
readonly variant = input<SideNavVariant>("primary");
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
constructor(protected sideNavService: SideNavService) {}
protected sideNavService = inject(SideNavService);
protected handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {

View File

@@ -2,25 +2,38 @@ import { Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs";
type CollapsePreference = "open" | "closed" | null;
const SMALL_SCREEN_BREAKPOINT_PX = 768;
@Injectable({
providedIn: "root",
})
export class SideNavService {
private _open$ = new BehaviorSubject<boolean>(!window.matchMedia("(max-width: 768px)").matches);
private _open$ = new BehaviorSubject<boolean>(
!window.matchMedia(`(max-width: ${SMALL_SCREEN_BREAKPOINT_PX}px)`).matches,
);
open$ = this._open$.asObservable();
private isSmallScreen$ = media("(max-width: 768px)");
private isSmallScreen$ = media(`(max-width: ${SMALL_SCREEN_BREAKPOINT_PX}px)`);
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
isOverlay$ = combineLatest([this.open$, this.isSmallScreen$]).pipe(
map(([open, isSmallScreen]) => open && isSmallScreen),
);
constructor() {
this.isSmallScreen$.pipe(takeUntilDestroyed()).subscribe((isSmallScreen) => {
if (isSmallScreen) {
this.setClose();
}
});
combineLatest([this.isSmallScreen$, this.userCollapsePreference$])
.pipe(takeUntilDestroyed())
.subscribe(([isSmallScreen, userCollapsePreference]) => {
if (isSmallScreen) {
this.setClose();
} else if (userCollapsePreference !== "closed") {
// Auto-open when user hasn't set preference (null) or prefers open
this.setOpen();
}
});
}
get open() {
@@ -37,6 +50,9 @@ export class SideNavService {
toggle() {
const curr = this._open$.getValue();
// Store user's preference based on what state they're toggling TO
this._userCollapsePreference$.next(curr ? "closed" : "open");
if (curr) {
this.setClose();
} else {

View File

@@ -200,3 +200,12 @@ export const VirtualScrollBlockingDialog: Story = {
await userEvent.click(dialogButton);
},
};
export const ResponsiveSidebar: Story = {
render: Default.render,
parameters: {
chromatic: {
viewports: [640, 1280],
},
},
};

View File

@@ -0,0 +1 @@
export * from "./switch.component";

View File

@@ -6,7 +6,6 @@ import { By } from "@angular/platform-browser";
import { BitLabel } from "../form-control/label.component";
import { SwitchComponent } from "./switch.component";
import { SwitchModule } from "./switch.module";
describe("SwitchComponent", () => {
let fixture: ComponentFixture<TestHostComponent>;
@@ -17,7 +16,7 @@ describe("SwitchComponent", () => {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "test-host",
imports: [FormsModule, BitLabel, ReactiveFormsModule, SwitchModule],
imports: [FormsModule, BitLabel, ReactiveFormsModule, SwitchComponent],
template: `
<form [formGroup]="formObj">
<bit-switch formControlName="switch">

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