1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 16:23:53 +00:00

Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-27632/sdk-cipher-ops

This commit is contained in:
Nik Gilmore
2026-01-06 15:13:34 -08:00
88 changed files with 3594 additions and 1074 deletions

View File

@@ -1884,6 +1884,286 @@ jobs:
upload_sources: true
upload_translations: false
validate-linux-x64-deb:
name: Validate Linux x64 .deb
runs-on: ubuntu-22.04
needs:
- setup
- linux
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Download deb artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/linux/deb
artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y libasound2 xvfb
- name: Install .deb
working-directory: apps/desktop/artifacts/linux/deb
run: sudo apt-get install -y ./Bitwarden-${_PACKAGE_VERSION}-amd64.deb
- name: Run .deb
run: |
xvfb-run -a bitwarden &
sleep 30
if pgrep bitwarden > /dev/null; then
pkill -9 bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
validate-linux-x64-appimage:
name: Validate Linux x64 appimage
runs-on: ubuntu-22.04
needs:
- setup
- linux
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Download appimage artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/linux/appimage
artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y libasound2 libfuse2 xvfb
- name: Run AppImage
working-directory: apps/desktop/artifacts/linux/appimage
run: |
chmod a+x ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage
xvfb-run -a ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage --no-sandbox &
sleep 30
if pgrep bitwarden > /dev/null; then
pkill -9 bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
validate-linux-wayland:
name: Validate Linux Wayland
runs-on: ubuntu-22.04
needs:
- setup
- linux
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Download appimage artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/linux/appimage
artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y libasound2 libfuse2 xvfb
sudo apt-get install -y weston libwayland-client0 libwayland-server0 libwayland-dev
- name: Run headless Wayland compositor
run: |
# Start Weston in a virtual terminal in headless mode
weston --headless --socket=wayland-0 &
# Let the compositor start
sleep 5
- name: Run AppImage
working-directory: apps/desktop/artifacts/linux/appimage
env:
WAYLAND_DISPLAY: wayland-0
run: |
chmod a+x ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage
xvfb-run -a ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage --no-sandbox &
sleep 30
if pgrep bitwarden > /dev/null; then
pkill -9 bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
validate-linux-flatpak:
name: Validate Linux ${{ matrix.os }} Flatpak
runs-on: ${{ matrix.os || 'ubuntu-22.04' }}
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-22.04-arm
needs:
- setup
- linux
- linux-arm64
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Download flatpak artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/linux/flatpak/
artifacts: com.bitwarden.${{ matrix.os == 'ubuntu-22.04' && 'desktop' || 'desktop-arm64' }}.flatpak
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y libasound2 flatpak xvfb dbus-x11
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub
- name: Install flatpak
working-directory: apps/desktop/artifacts/linux/flatpak
run: flatpak install -y --user --bundle com.bitwarden.desktop.flatpak
- name: Run Flatpak
run: |
export $(dbus-launch)
xvfb-run -a flatpak run com.bitwarden.desktop &
sleep 30
if pgrep bitwarden > /dev/null; then
pkill -9 bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
validate-linux-snap:
name: Validate Linux ${{ matrix.os }} Snap
runs-on: ${{ matrix.os || 'ubuntu-22.04' }}
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-22.04-arm
needs:
- setup
- linux
- linux-arm64
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }}
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Download snap artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/linux/snap
artifacts: bitwarden_${{ env._PACKAGE_VERSION }}_${{ env._CPU_ARCH }}.snap
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y libasound2 snapd xvfb
- name: Install snap
working-directory: apps/desktop/artifacts/linux/snap
run: |
sudo snap install --dangerous ./bitwarden_${_PACKAGE_VERSION}_${_CPU_ARCH}.snap
- name: Run Snap
run: |
xvfb-run -a snap run bitwarden &
sleep 30
if pgrep bitwarden > /dev/null; then
pkill -9 bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
validate-macos-dmg:
name: Validate MacOS dmg
runs-on: macos-15
needs:
- setup
- macos-package-github
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Download dmg artifact
uses: bitwarden/gh-actions/download-artifacts@main
with:
path: apps/desktop/artifacts/macos/dmg
artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg
- name: Install dmg
working-directory: apps/desktop/artifacts/macos/dmg
run: |
# mount
hdiutil attach Bitwarden-${_PACKAGE_VERSION}-universal.dmg
# install
cp -r /Volumes/Bitwarden\ ${_PACKAGE_VERSION}-universal/Bitwarden.app /Applications/
# unmount
hdiutil detach /Volumes/Bitwarden\ ${_PACKAGE_VERSION}-universal
- name: Run dmg
run: |
open /Applications/Bitwarden.app/Contents/MacOS/Bitwarden &
sleep 30
if pgrep Bitwarden > /dev/null; then
pkill -9 Bitwarden
echo "Bitwarden is running."
else
echo "Bitwarden is not running."
exit 1
fi
check-failures:
name: Check for failures
if: always()
@@ -1898,6 +2178,12 @@ jobs:
- macos-package-github
- macos-package-mas
- crowdin-push
- validate-linux-x64-deb
- validate-linux-x64-appimage
- validate-linux-flatpak
- validate-linux-snap
- validate-linux-wayland
- validate-macos-dmg
permissions:
contents: read
id-token: write

View File

@@ -585,6 +585,9 @@
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
},
"itemRestored": {
"message": "Item has been restored"
},
"edit": {
"message": "Edit"
},
@@ -5664,6 +5667,12 @@
"changeAtRiskPasswordAndAddWebsite": {
"message": "This login is at-risk and missing a website. Add a website and change the password for stronger security."
},
"vulnerablePassword": {
"message": "Vulnerable password."
},
"changeNow": {
"message": "Change now"
},
"missingWebsite": {
"message": "Missing website"
},
@@ -6018,7 +6027,7 @@
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
"example": "My Org Name"
}
}
},
@@ -6027,7 +6036,7 @@
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
"example": "My Org Name"
}
}
},

View File

@@ -0,0 +1,242 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { ExtensionAuthRequestAnsweringService } from "./extension-auth-request-answering.service";
describe("ExtensionAuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let authService: MockProxy<AuthService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let actionService: MockProxy<ActionsService>;
let i18nService: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let systemNotificationsService: MockProxy<SystemNotificationsService>;
let logService: MockProxy<LogService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
const userAccountInfo = mockAccountInfoWith({
name: "User",
email: "user@example.com",
});
const userAccount: Account = {
id: userId,
...userAccountInfo,
};
const authRequestId = "auth-request-id-123";
beforeEach(() => {
accountService = mock<AccountService>();
authService = mock<AuthService>();
masterPasswordService = {
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
};
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
actionService = mock<ActionsService>();
i18nService = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>();
systemNotificationsService = mock<SystemNotificationsService>();
logService = mock<LogService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of(userAccount);
accountService.accounts$ = of({
[userId]: userAccountInfo,
});
platformUtilsService.isPopupOpen.mockResolvedValue(false);
i18nService.t.mockImplementation(
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
);
systemNotificationsService.create.mockResolvedValue("notif-id");
sut = new ExtensionAuthRequestAnsweringService(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
actionService,
i18nService,
platformUtilsService,
systemNotificationsService,
logService,
);
});
describe("receivedPendingAuthRequest()", () => {
it("should throw if authRequestUserId not given", async () => {
// Act
const promise = sut.receivedPendingAuthRequest(undefined, authRequestId);
// Assert
await expect(promise).rejects.toThrow("authRequestUserId required");
});
it("should throw if authRequestId not given", async () => {
// Act
const promise = sut.receivedPendingAuthRequest(userId, undefined);
// Assert
await expect(promise).rejects.toThrow("authRequestId required");
});
it("should add a pending marker for the user to state", async () => {
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(pendingAuthRequestsState.add).toHaveBeenCalledTimes(1);
expect(pendingAuthRequestsState.add).toHaveBeenCalledWith(userId);
});
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
describe("given the popup is open", () => {
it("should send an 'openLoginApproval' message", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
notificationId: authRequestId,
});
});
it("should not create a system notification", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(systemNotificationsService.create).not.toHaveBeenCalled();
});
});
describe("given the popup is closed", () => {
it("should not send an 'openLoginApproval' message", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should create a system notification", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
expect(systemNotificationsService.create).toHaveBeenCalledWith({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
title: "accountAccessRequested",
body: "confirmAccessAttempt:user@example.com",
buttons: [],
});
});
});
});
});
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
it("should return true if popup is open", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(true);
});
it("should return false if popup is closed", async () => {
// Arrange
platformUtilsService.isPopupOpen.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
});
});
describe("handleAuthRequestNotificationClicked()", () => {
it("should clear notification and open popup when notification body is clicked", async () => {
// Arrange
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.NotificationButton,
};
// Act
await sut.handleAuthRequestNotificationClicked(event);
// Assert
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
});
it("should do nothing when an optional notification button is clicked", async () => {
// Arrange
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.FirstOptionalButton,
};
// Act
await sut.handleAuthRequestNotificationClicked(event);
// Assert
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
expect(actionService.openPopup).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,116 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
export class ExtensionAuthRequestAnsweringService
extends DefaultAuthRequestAnsweringService
implements AuthRequestAnsweringService
{
constructor(
protected readonly accountService: AccountService,
protected readonly authService: AuthService,
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
protected readonly messagingService: MessagingService,
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
private readonly actionService: ActionsService,
private readonly i18nService: I18nService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly systemNotificationsService: SystemNotificationsService,
private readonly logService: LogService,
) {
super(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
);
}
async receivedPendingAuthRequest(
authRequestUserId: UserId,
authRequestId: string,
): Promise<void> {
if (!authRequestUserId) {
throw new Error("authRequestUserId required");
}
if (!authRequestId) {
throw new Error("authRequestId required");
}
// Always persist the pending marker for this user to global state.
await this.pendingAuthRequestsState.add(authRequestUserId);
const activeUserMeetsConditionsToShowApprovalDialog =
await this.activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId);
if (activeUserMeetsConditionsToShowApprovalDialog) {
// Send message to open dialog immediately for this request
this.messagingService.send("openLoginApproval", {
// Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
// This will only matter if the user is on the /device-management screen when the auth request is received.
notificationId: authRequestId,
});
} else {
// Create a system notification
const accounts = await firstValueFrom(this.accountService.accounts$);
const accountInfo = accounts[authRequestUserId];
if (!accountInfo) {
this.logService.error("Account not found for authRequestUserId");
return;
}
const emailForUser = accountInfo.email;
await this.systemNotificationsService.create({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
title: this.i18nService.t("accountAccessRequested"),
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
buttons: [],
});
}
}
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
const meetsBasicConditions = await super.activeUserMeetsConditionsToShowApprovalDialog(
authRequestUserId,
);
// To show an approval dialog immediately on Extension, the popup must be open.
const isPopupOpen = await this.platformUtilsService.isPopupOpen();
const meetsExtensionConditions = meetsBasicConditions && isPopupOpen;
return meetsExtensionConditions;
}
/**
* When a system notification is clicked, this function is used to process that event.
*
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
*/
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
await this.systemNotificationsService.clear({
id: `${event.id}`,
});
await this.actionService.openPopup();
}
}
}

View File

@@ -40,7 +40,7 @@ import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/p
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
@@ -52,7 +52,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
@@ -275,6 +274,7 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
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";
@@ -392,7 +392,7 @@ export default class MainBackground {
serverNotificationsService: ServerNotificationsService;
systemNotificationService: SystemNotificationsService;
actionsService: ActionsService;
authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction;
authRequestAnsweringService: AuthRequestAnsweringService;
stateService: StateServiceAbstraction;
userNotificationSettingsService: UserNotificationSettingsServiceAbstraction;
autofillSettingsService: AutofillSettingsServiceAbstraction;
@@ -1209,16 +1209,17 @@ export default class MainBackground {
this.pendingAuthRequestStateService = new PendingAuthRequestsStateService(this.stateProvider);
this.authRequestAnsweringService = new AuthRequestAnsweringService(
this.authRequestAnsweringService = new ExtensionAuthRequestAnsweringService(
this.accountService,
this.actionsService,
this.authService,
this.i18nService,
this.masterPasswordService,
this.messagingService,
this.pendingAuthRequestStateService,
this.actionsService,
this.i18nService,
this.platformUtilsService,
this.systemNotificationService,
this.logService,
);
this.serverNotificationsService = new DefaultServerNotificationsService(

View File

@@ -14,16 +14,11 @@ import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import {
catchError,
concatMap,
distinctUntilChanged,
filter,
firstValueFrom,
map,
of,
pairwise,
startWith,
Subject,
switchMap,
take,
takeUntil,
tap,
} from "rxjs";
@@ -38,7 +33,7 @@ import {
} from "@bitwarden/auth/common";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -83,7 +78,8 @@ export class AppComponent implements OnInit, OnDestroy {
private lastActivity: Date;
private activeUserId: UserId;
private routerAnimations = false;
private processingPendingAuth = false;
private processingPendingAuthRequests = false;
private shouldRerunAuthRequestProcessing = false;
private destroy$ = new Subject<void>();
@@ -118,7 +114,7 @@ export class AppComponent implements OnInit, OnDestroy {
private logService: LogService,
private authRequestService: AuthRequestServiceAbstraction,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
private authRequestAnsweringService: AuthRequestAnsweringService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -136,22 +132,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.activeUserId = account?.id;
});
// Trigger processing auth requests when the active user is in an unlocked state. Runs once when
// the popup is open.
this.accountService.activeAccount$
.pipe(
map((a) => a?.id), // Extract active userId
distinctUntilChanged(), // Only when userId actually changes
filter((userId) => userId != null), // Require a valid userId
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
tap(() => {
// Trigger processing when switching users while popup is open
void this.authRequestAnsweringService.processPendingAuthRequests();
}),
takeUntil(this.destroy$),
)
.subscribe();
this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$);
this.authService.activeAccountStatus$
.pipe(
@@ -163,23 +144,6 @@ export class AppComponent implements OnInit, OnDestroy {
)
.subscribe();
// When the popup is already open and the active account transitions to Unlocked,
// process any pending auth requests for the active user. The above subscription does not handle
// this case.
this.authService.activeAccountStatus$
.pipe(
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
pairwise(), // Compare previous and current statuses
filter(
([prev, curr]) =>
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
),
takeUntil(this.destroy$),
)
.subscribe(() => {
void this.authRequestAnsweringService.processPendingAuthRequests();
});
this.ngZone.runOutsideAngular(() => {
window.onmousedown = () => this.recordActivity();
window.ontouchstart = () => this.recordActivity();
@@ -234,38 +198,31 @@ export class AppComponent implements OnInit, OnDestroy {
await this.router.navigate(["lock"]);
} else if (msg.command === "openLoginApproval") {
if (this.processingPendingAuth) {
if (this.processingPendingAuthRequests) {
// If an "openLoginApproval" message is received while we are currently processing other
// auth requests, then set a flag so we remember to process that new auth request
this.shouldRerunAuthRequestProcessing = true;
return;
}
this.processingPendingAuth = true;
try {
// Always query server for all pending requests and open a dialog for each
const pendingList = await firstValueFrom(
this.authRequestService.getPendingAuthRequests$(),
);
if (Array.isArray(pendingList) && pendingList.length > 0) {
const respondedIds = new Set<string>();
for (const req of pendingList) {
if (req?.id == null) {
continue;
}
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: req.id,
});
const result = await firstValueFrom(dialogRef.closed);
/**
* This do/while loop allows us to:
* - a) call processPendingAuthRequests() once on "openLoginApproval"
* - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was
* received while we were processing the original auth requests
*/
do {
this.shouldRerunAuthRequestProcessing = false;
if (result !== undefined && typeof result === "boolean") {
respondedIds.add(req.id);
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
await this.pendingAuthRequestsState.clear(this.activeUserId);
}
}
}
try {
await this.processPendingAuthRequests();
} catch (error) {
this.logService.error(`Error processing pending auth requests: ${error}`);
this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors
}
} finally {
this.processingPendingAuth = false;
}
// If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then
// shouldRerunAuthRequestProcessing will have been set to true
} while (this.shouldRerunAuthRequestProcessing);
} else if (msg.command === "showDialog") {
// 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
@@ -403,4 +360,39 @@ export class AppComponent implements OnInit, OnDestroy {
this.toastService.showToast(toastOptions);
}
private async processPendingAuthRequests() {
this.processingPendingAuthRequests = true;
try {
// Always query server for all pending requests and open a dialog for each
const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$());
if (Array.isArray(pendingList) && pendingList.length > 0) {
const respondedIds = new Set<string>();
for (const req of pendingList) {
if (req?.id == null) {
continue;
}
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: req.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
respondedIds.add(req.id);
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
await this.pendingAuthRequestsState.clear(this.activeUserId);
}
}
}
}
} finally {
this.processingPendingAuthRequests = false;
}
}
}

View File

@@ -40,6 +40,7 @@ import {
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -49,13 +50,12 @@ import {
AccountService,
AccountService as AccountServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import {
AutofillSettingsService,
@@ -494,18 +494,19 @@ const safeProviders: SafeProvider[] = [
deps: [],
}),
safeProvider({
provide: AuthRequestAnsweringServiceAbstraction,
useClass: AuthRequestAnsweringService,
provide: AuthRequestAnsweringService,
useClass: ExtensionAuthRequestAnsweringService,
deps: [
AccountServiceAbstraction,
ActionsService,
AuthService,
I18nServiceAbstraction,
MasterPasswordServiceAbstraction,
MessagingService,
PendingAuthRequestsStateService,
ActionsService,
I18nServiceAbstraction,
PlatformUtilsService,
SystemNotificationsService,
LogService,
],
}),
safeProvider({

View File

@@ -822,7 +822,6 @@ function createSeededVaultPopupListFiltersService(
accountServiceMock,
viewCacheServiceMock,
restrictedItemTypesServiceMock,
configService,
);
});

View File

@@ -29,8 +29,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -191,7 +189,6 @@ export class VaultPopupListFiltersService {
private accountService: AccountService,
private viewCacheService: ViewCacheService,
private restrictedItemTypesService: RestrictedItemTypesService,
private configService: ConfigService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
@@ -455,19 +452,15 @@ export class VaultPopupListFiltersService {
),
this.collectionService.decryptedCollections$(userId),
this.organizationService.memberOrganizations$(userId),
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
]),
),
map(([filters, allCollections, orgs, defaultVaultEnabled]) => {
map(([filters, allCollections, orgs]) => {
const orgFilterId = filters.organization?.id ?? null;
// When the organization filter is selected, filter out collections that do not belong to the selected organization
const filtered = orgFilterId
? allCollections.filter((c) => c.organizationId === orgFilterId)
: allCollections;
if (!defaultVaultEnabled) {
return filtered;
}
return sortDefaultCollections(filtered, orgs, this.i18nService.collator);
}),
map((fullList) => {

View File

@@ -9,9 +9,7 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
@@ -28,7 +26,6 @@ export class ConfirmCommand {
private encryptService: EncryptService,
private organizationUserApiService: OrganizationUserApiService,
private accountService: AccountService,
private configService: ConfigService,
private i18nService: I18nService,
) {}
@@ -80,11 +77,7 @@ export class ConfirmCommand {
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const req = new OrganizationUserConfirmRequest();
req.key = key.encryptedString;
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
req.defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
}
req.defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
await this.organizationUserApiService.postOrganizationUserConfirm(
options.organizationId,
id,

View File

@@ -147,7 +147,6 @@ export class OssServeConfigurator {
this.serviceContainer.encryptService,
this.serviceContainer.organizationUserApiService,
this.serviceContainer.accountService,
this.serviceContainer.configService,
this.serviceContainer.i18nService,
);
this.restoreCommand = new RestoreCommand(

View File

@@ -494,7 +494,6 @@ export class VaultProgram extends BaseProgram {
this.serviceContainer.encryptService,
this.serviceContainer.organizationUserApiService,
this.serviceContainer.accountService,
this.serviceContainer.configService,
this.serviceContainer.i18nService,
);
const response = await command.run(object, id, cmd);

View File

@@ -40,8 +40,8 @@
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .",
"pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && cp ./resources/com.bitwarden.desktop.desktop ./dist/linux-unpacked/resources && cp -r ./resources/icons ./dist/linux-unpacked/resources && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .",
"pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && cp ./resources/com.bitwarden.desktop.desktop ./dist/linux-arm64-unpacked/resources && cp -r ./resources/icons ./dist/linux-arm64-unpacked/resources && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",

View File

@@ -31,6 +31,7 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
DESKTOP_SSO_CALLBACK,
LockService,
LogoutReason,
@@ -40,11 +41,13 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -151,6 +154,8 @@ export class AppComponent implements OnInit, OnDestroy {
private isIdle = false;
private activeUserId: UserId = null;
private activeSimpleDialog: DialogRef<boolean> = null;
private processingPendingAuthRequests = false;
private shouldRerunAuthRequestProcessing = false;
private destroy$ = new Subject<void>();
@@ -200,6 +205,9 @@ export class AppComponent implements OnInit, OnDestroy {
private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy,
private readonly lockService: LockService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestService: AuthRequestServiceAbstraction,
private authRequestAnsweringService: AuthRequestAnsweringService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -212,6 +220,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.activeUserId = account?.id;
});
this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$);
this.ngZone.runOutsideAngular(() => {
setTimeout(async () => {
await this.updateAppMenu();
@@ -499,13 +509,31 @@ export class AppComponent implements OnInit, OnDestroy {
await this.checkForSystemTimeout(VaultTimeoutStringType.OnIdle);
break;
case "openLoginApproval":
if (message.notificationId != null) {
this.dialogService.closeAll();
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: message.notificationId,
});
await firstValueFrom(dialogRef.closed);
if (this.processingPendingAuthRequests) {
// If an "openLoginApproval" message is received while we are currently processing other
// auth requests, then set a flag so we remember to process that new auth request
this.shouldRerunAuthRequestProcessing = true;
return;
}
/**
* This do/while loop allows us to:
* - a) call processPendingAuthRequests() once on "openLoginApproval"
* - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was
* received while we were processing the original auth requests
*/
do {
this.shouldRerunAuthRequestProcessing = false;
try {
await this.processPendingAuthRequests();
} catch (error) {
this.logService.error(`Error processing pending auth requests: ${error}`);
this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors
}
// If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then
// shouldRerunAuthRequestProcessing will have been set to true
} while (this.shouldRerunAuthRequestProcessing);
break;
case "redrawMenu":
await this.updateAppMenu();
@@ -887,4 +915,39 @@ export class AppComponent implements OnInit, OnDestroy {
DeleteAccountComponent.open(this.dialogService);
}
private async processPendingAuthRequests() {
this.processingPendingAuthRequests = true;
try {
// Always query server for all pending requests and open a dialog for each
const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$());
if (Array.isArray(pendingList) && pendingList.length > 0) {
const respondedIds = new Set<string>();
for (const req of pendingList) {
if (req?.id == null) {
continue;
}
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: req.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
respondedIds.add(req.id);
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
await this.pendingAuthRequestsState.clear(this.activeUserId);
}
}
}
}
} finally {
this.processingPendingAuthRequests = false;
}
}
}

View File

@@ -1,9 +1,13 @@
<bit-layout class="!tw-h-full">
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n" />
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
<app-send-filters-nav></app-send-filters-nav>
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault" />
<app-send-filters-nav />
<bit-nav-item icon="bwi-generate" [text]="'generator' | i18n" (click)="openGenerator()" />
<bit-nav-item icon="bwi-import" [text]="'importNoun' | i18n" (click)="openImport()" />
<bit-nav-item icon="bwi-download" [text]="'exportNoun' | i18n" (click)="openExport()" />
</app-side-nav>
<router-outlet></router-outlet>

View File

@@ -5,7 +5,7 @@ import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { NavigationModule } from "@bitwarden/components";
import { DialogService, NavigationModule } from "@bitwarden/components";
import { GlobalStateProvider } from "@bitwarden/state";
import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
@@ -52,6 +52,10 @@ describe("DesktopLayoutComponent", () => {
provide: GlobalStateProvider,
useValue: fakeGlobalStateProvider,
},
{
provide: DialogService,
useValue: mock<DialogService>(),
},
],
})
.overrideComponent(DesktopLayoutComponent, {

View File

@@ -1,10 +1,13 @@
import { Component } from "@angular/core";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { DialogService, LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ExportDesktopComponent } from "../tools/export/export-desktop.component";
import { CredentialGeneratorComponent } from "../tools/generator/credential-generator.component";
import { ImportDesktopComponent } from "../tools/import/import-desktop.component";
import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
@@ -24,5 +27,19 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component";
templateUrl: "./desktop-layout.component.html",
})
export class DesktopLayoutComponent {
private dialogService = inject(DialogService);
protected readonly logo = PasswordManagerLogo;
protected openGenerator() {
this.dialogService.open(CredentialGeneratorComponent);
}
protected openImport() {
this.dialogService.open(ImportDesktopComponent);
}
protected openExport() {
this.dialogService.open(ExportDesktopComponent);
}
}

View File

@@ -5,7 +5,6 @@ import { Router } from "@angular/router";
import { Subject, merge } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { LoginApprovalDialogComponentServiceAbstraction } from "@bitwarden/angular/auth/login-approval";
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import {
@@ -45,6 +44,7 @@ import {
AccountService,
AccountService as AccountServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
AuthService,
AuthService as AuthServiceAbstraction,
@@ -52,6 +52,7 @@ import {
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ClientType } from "@bitwarden/common/enums";
@@ -61,7 +62,10 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
@@ -120,8 +124,8 @@ import {
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service";
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
@@ -469,11 +473,6 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSsoComponentService,
deps: [],
}),
safeProvider({
provide: LoginApprovalDialogComponentServiceAbstraction,
useClass: DesktopLoginApprovalDialogComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
@@ -509,6 +508,19 @@ const safeProviders: SafeProvider[] = [
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction],
}),
safeProvider({
provide: AuthRequestAnsweringService,
useClass: DesktopAuthRequestAnsweringService,
deps: [
AccountServiceAbstraction,
AuthService,
MasterPasswordServiceAbstraction,
MessagingServiceAbstraction,
PendingAuthRequestsStateService,
I18nServiceAbstraction,
LogService,
],
}),
];
@NgModule({

View File

@@ -1,91 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { Subject } from "rxjs";
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DesktopLoginApprovalDialogComponentService } from "./desktop-login-approval-dialog-component.service";
describe("DesktopLoginApprovalDialogComponentService", () => {
let service: DesktopLoginApprovalDialogComponentService;
let i18nService: MockProxy<I18nServiceAbstraction>;
let originalIpc: any;
beforeEach(() => {
originalIpc = (global as any).ipc;
(global as any).ipc = {
auth: {
loginRequest: jest.fn(),
},
platform: {
isWindowVisible: jest.fn(),
},
};
i18nService = mock<I18nServiceAbstraction>({
t: jest.fn(),
userSetLocale$: new Subject<string>(),
locale$: new Subject<string>(),
});
TestBed.configureTestingModule({
providers: [
DesktopLoginApprovalDialogComponentService,
{ provide: I18nServiceAbstraction, useValue: i18nService },
],
});
service = TestBed.inject(DesktopLoginApprovalDialogComponentService);
});
afterEach(() => {
jest.clearAllMocks();
(global as any).ipc = originalIpc;
});
it("is created successfully", () => {
expect(service).toBeTruthy();
});
it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => {
const title = "Log in requested";
const email = "test@bitwarden.com";
const message = `Confirm access attempt for ${email}`;
const closeText = "Close";
const loginApprovalDialogComponent = { email } as LoginApprovalDialogComponent;
i18nService.t.mockImplementation((key: string) => {
switch (key) {
case "accountAccessRequested":
return title;
case "confirmAccessAttempt":
return message;
case "close":
return closeText;
default:
return "";
}
});
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false);
jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue();
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText);
});
it("does not call ipc.auth.loginRequest when window is visible", async () => {
const loginApprovalDialogComponent = {
email: "test@bitwarden.com",
} as LoginApprovalDialogComponent;
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true);
jest.spyOn(ipc.auth, "loginRequest");
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
expect(ipc.auth.loginRequest).not.toHaveBeenCalled();
});
});

View File

@@ -1,28 +0,0 @@
import { Injectable } from "@angular/core";
import {
DefaultLoginApprovalDialogComponentService,
LoginApprovalDialogComponentServiceAbstraction,
} from "@bitwarden/angular/auth/login-approval";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
@Injectable()
export class DesktopLoginApprovalDialogComponentService
extends DefaultLoginApprovalDialogComponentService
implements LoginApprovalDialogComponentServiceAbstraction
{
constructor(private i18nService: I18nServiceAbstraction) {
super();
}
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
const isVisible = await ipc.platform.isWindowVisible();
if (!isVisible) {
await ipc.auth.loginRequest(
this.i18nService.t("accountAccessRequested"),
this.i18nService.t("confirmAccessAttempt", email),
this.i18nService.t("close"),
);
}
}
}

View File

@@ -0,0 +1,277 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { DesktopAuthRequestAnsweringService } from "./desktop-auth-request-answering.service";
describe("DesktopAuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let authService: MockProxy<AuthService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
const userAccountInfo = mockAccountInfoWith({
name: "User",
email: "user@example.com",
});
const userAccount: Account = {
id: userId,
...userAccountInfo,
};
const authRequestId = "auth-request-id-123";
beforeEach(() => {
(global as any).ipc = {
platform: {
isWindowVisible: jest.fn(),
},
auth: {
loginRequest: jest.fn(),
},
};
accountService = mock<AccountService>();
authService = mock<AuthService>();
masterPasswordService = {
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
};
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
i18nService = mock<I18nService>();
logService = mock<LogService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of(userAccount);
accountService.accounts$ = of({
[userId]: userAccountInfo,
});
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
i18nService.t.mockImplementation(
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
);
sut = new DesktopAuthRequestAnsweringService(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
i18nService,
logService,
);
});
describe("receivedPendingAuthRequest()", () => {
it("should throw if authRequestUserId not given", async () => {
// Act
const promise = sut.receivedPendingAuthRequest(undefined, undefined);
// Assert
await expect(promise).rejects.toThrow("authRequestUserId required");
});
it("should add a pending marker for the user to state", async () => {
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(pendingAuthRequestsState.add).toHaveBeenCalledTimes(1);
expect(pendingAuthRequestsState.add).toHaveBeenCalledWith(userId);
});
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
describe("given the Desktop window is visible", () => {
it("should send an 'openLoginApproval' message", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT create a system notification", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect((global as any).ipc.auth.loginRequest).not.toHaveBeenCalled();
});
});
describe("given the Desktop window is NOT visible", () => {
it("should STILL send an 'openLoginApproval' message", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should create a system notification", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
expect(i18nService.t).toHaveBeenCalledWith("close");
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
"accountAccessRequested",
"confirmAccessAttempt:user@example.com",
"close",
);
});
});
});
describe("given the active user is Locked", () => {
it("should NOT send an 'openLoginApproval' message", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should create a system notification", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
"accountAccessRequested",
"confirmAccessAttempt:user@example.com",
"close",
);
});
});
describe("given the active user is not the intended recipient of the auth request", () => {
beforeEach(() => {
// Different active user for these tests
const differentUserId = "different-user-id" as UserId;
accountService.activeAccount$ = of({
id: differentUserId,
...mockAccountInfoWith({
name: "Different User",
email: "different@example.com",
}),
});
});
it("should NOT send an 'openLoginApproval' message", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
// Pass in userId, not differentUserId (the active user), to mimic an auth
// request coming in for a user who is not the active user
await sut.receivedPendingAuthRequest(userId, authRequestId); // pass in userId, not differentUserId
// Assert
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should create a system notification", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
// Pass in userId, not differentUserId (the active user), to mimic an auth
// request coming in for a user who is not the active user
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
"accountAccessRequested",
"confirmAccessAttempt:user@example.com",
"close",
);
});
});
describe("given the active user is required to set/change their master password", () => {
it("should NOT send an 'openLoginApproval' message", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
masterPasswordService.forceSetPasswordReason$ = jest
.fn()
.mockReturnValue(of(ForceSetPasswordReason.WeakMasterPassword));
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should create a system notification", async () => {
// Arrange
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
masterPasswordService.forceSetPasswordReason$ = jest
.fn()
.mockReturnValue(of(ForceSetPasswordReason.WeakMasterPassword));
// Act
await sut.receivedPendingAuthRequest(userId, authRequestId);
// Assert
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
"accountAccessRequested",
"confirmAccessAttempt:user@example.com",
"close",
);
});
});
});
});

View File

@@ -0,0 +1,85 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
export class DesktopAuthRequestAnsweringService
extends DefaultAuthRequestAnsweringService
implements AuthRequestAnsweringService
{
constructor(
protected readonly accountService: AccountService,
protected readonly authService: AuthService,
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
protected readonly messagingService: MessagingService,
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
private readonly i18nService: I18nService,
private readonly logService: LogService,
) {
super(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
);
}
/**
* @param authRequestUserId The UserId that the auth request is for.
* @param authRequestId The authRequestId param is not used on Desktop because clicks on a
* Desktop notification do not run any auth-request-specific actions.
* All clicks simply open the Desktop window. See electron-main-messaging.service.ts.
*/
async receivedPendingAuthRequest(
authRequestUserId: UserId,
authRequestId: string,
): Promise<void> {
if (!authRequestUserId) {
throw new Error("authRequestUserId required");
}
// Always persist the pending marker for this user to global state.
await this.pendingAuthRequestsState.add(authRequestUserId);
const activeUserMeetsConditionsToShowApprovalDialog =
await this.activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId);
if (activeUserMeetsConditionsToShowApprovalDialog) {
// Send message to open dialog immediately for this request
this.messagingService.send("openLoginApproval");
}
const isWindowVisible = await ipc.platform.isWindowVisible();
// Create a system notification if either of the following are true:
// - User does NOT meet conditions to show dialog
// - User does meet conditions, but the Desktop window is not visible
// - In this second case, we both send the "openLoginApproval" message (above) AND
// also create the system notification to notify the user that the dialog is there.
if (!activeUserMeetsConditionsToShowApprovalDialog || !isWindowVisible) {
const accounts = await firstValueFrom(this.accountService.accounts$);
const accountInfo = accounts[authRequestUserId];
if (!accountInfo) {
this.logService.error("Account not found for authRequestUserId");
return;
}
const emailForUser = accountInfo.email;
await ipc.auth.loginRequest(
this.i18nService.t("accountAccessRequested"),
this.i18nService.t("confirmAccessAttempt", emailForUser),
this.i18nService.t("close"),
);
}
}
}

View File

@@ -4306,6 +4306,9 @@
"unArchive": {
"message": "Unarchive"
},
"archived": {
"message": "Archived"
},
"itemsInArchive": {
"message": "Items in archive"
},
@@ -4327,6 +4330,21 @@
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
},
"unArchiveAndSave": {
"message": "Unarchive and save"
},
"restartPremium": {
"message": "Restart Premium"
},
"premiumSubscriptionEnded": {
"message": "Your Premium subscription ended"
},
"premiumSubscriptionEndedDesc": {
"message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, itll be moved back into your vault."
},
"itemRestored": {
"message": "Item has been restored"
},
"zipPostalCodeLabel": {
"message": "ZIP / Postal code"
},
@@ -4475,7 +4493,7 @@
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
"example": "My Org Name"
}
}
},
@@ -4484,7 +4502,7 @@
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
"example": "My Org Name"
}
}
},

View File

@@ -7,9 +7,9 @@
[hidden]="action === 'view'"
bitButton
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
appA11yTitle="{{ submitButtonText() }}"
>
{{ "save" | i18n }}
{{ submitButtonText() }}
</button>
<button
type="button"
@@ -59,7 +59,7 @@
type="button"
*ngIf="showUnarchiveButton"
(click)="unarchive()"
appA11yTitle="{{ 'unarchive' | i18n }}"
appA11yTitle="{{ 'unArchive' | i18n }}"
>
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>

View File

@@ -8,6 +8,7 @@ import {
ViewChild,
OnChanges,
SimpleChanges,
input,
} from "@angular/core";
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
@@ -67,6 +68,8 @@ export class ItemFooterComponent implements OnInit, OnChanges {
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
readonly submitButtonText = input<string>(this.i18nService.t("save"));
activeUserId: UserId | null = null;
passwordReprompted: boolean = false;
@@ -218,7 +221,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
private async checkArchiveState() {
const cipherCanBeArchived = !this.cipher.isDeleted && this.cipher.organizationId == null;
const cipherCanBeArchived = !this.cipher.isDeleted;
const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,

View File

@@ -2,6 +2,21 @@
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<ng-container *ngIf="loaded">
@if (showPremiumCallout()) {
<div class="tw-m-4">
<bit-callout type="default" [title]="'premiumSubscriptionEnded' | i18n">
<ng-container>
<div>
{{ "premiumSubscriptionEndedDesc" | i18n }}
</div>
<a bitLink href="#" appStopClick (click)="navigateToGetPremium()">
{{ "restartPremium" | i18n }}
</a>
</ng-container>
</bit-callout>
</div>
}
<div class="content">
<cdk-virtual-scroll-viewport
itemSize="42"

View File

@@ -1,6 +1,6 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Component, input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { distinctUntilChanged, debounceTime } from "rxjs";
@@ -9,7 +9,9 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
@@ -17,7 +19,7 @@ import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuModule } from "@bitwarden/components";
import { CalloutComponent, MenuModule } from "@bitwarden/components";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
@@ -26,10 +28,14 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
@Component({
selector: "app-vault-items-v2",
templateUrl: "vault-items-v2.component.html",
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule],
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule, CalloutComponent],
})
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
readonly showPremiumCallout = input<boolean>(false);
readonly organizationId = input<OrganizationId | undefined>(undefined);
protected CipherViewLikeUtils = CipherViewLikeUtils;
constructor(
searchService: SearchService,
private readonly searchBarService: SearchBarService,
@@ -37,6 +43,7 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
accountService: AccountService,
restrictedItemTypesService: RestrictedItemTypesService,
configService: ConfigService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(searchService, cipherService, accountService, restrictedItemTypesService, configService);
@@ -47,6 +54,10 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
});
}
async navigateToGetPremium() {
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
}
trackByFn(index: number, c: C): string {
return uuidAsString(c.id!);
}

View File

@@ -6,13 +6,15 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
[showPremiumCallout]="showPremiumCallout$ | async"
[organizationId]="organizationId"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[cipher]="cipher()"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
@@ -21,11 +23,16 @@
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
[submitButtonText]="submitButtonText()"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
<app-cipher-view
*ngIf="action === 'view'"
[cipher]="cipher()"
[collections]="collections"
>
</app-cipher-view>
<vault-cipher-form
#vaultForm

View File

@@ -2,21 +2,31 @@ import { CommonModule } from "@angular/common";
import {
ChangeDetectorRef,
Component,
computed,
NgZone,
OnDestroy,
OnInit,
signal,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs";
import {
firstValueFrom,
Subject,
takeUntil,
switchMap,
lastValueFrom,
Observable,
BehaviorSubject,
combineLatest,
} from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -24,11 +34,12 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
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";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -163,7 +174,7 @@ export class VaultV2Component<C extends CipherViewLike>
type: CipherType | null = null;
folderId: string | null = null;
collectionId: string | null = null;
organizationId: string | null = null;
organizationId: OrganizationId | null = null;
myVaultOnly = false;
addType: CipherType | undefined = undefined;
addOrganizationId: string | null = null;
@@ -172,11 +183,25 @@ export class VaultV2Component<C extends CipherViewLike>
deleted = false;
userHasPremiumAccess = false;
activeFilter: VaultFilter = new VaultFilter();
private activeFilterSubject = new BehaviorSubject<VaultFilter>(new VaultFilter());
private activeFilter$ = this.activeFilterSubject.asObservable();
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
showPremiumCallout$ = this.userId$.pipe(
switchMap((userId) =>
combineLatest([
this.activeFilter$,
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
]).pipe(
map(([activeFilter, showMessaging]) => activeFilter.status === "archive" && showMessaging),
),
),
);
activeUserId: UserId | null = null;
cipherRepromptId: string | null = null;
cipher: CipherView | null = new CipherView();
readonly cipher = signal<CipherView | null>(null);
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
readonly userHasPremium = signal<boolean>(false);
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
@@ -187,12 +212,13 @@ export class VaultV2Component<C extends CipherViewLike>
switchMap((id) => this.organizationService.organizations$(id)),
);
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
filter((account): account is Account => !!account),
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
),
);
protected readonly submitButtonText = computed(() => {
return this.cipher()?.isArchived &&
!this.userHasPremium() &&
this.cipherArchiveService.hasArchiveFlagEnabled$
? this.i18nService.t("unArchiveAndSave")
: this.i18nService.t("save");
});
private componentIsDestroyed$ = new Subject<boolean>();
private allOrganizations: Organization[] = [];
@@ -223,11 +249,10 @@ export class VaultV2Component<C extends CipherViewLike>
private collectionService: CollectionService,
private organizationService: OrganizationService,
private folderService: FolderService,
private configService: ConfigService,
private authRequestService: AuthRequestServiceAbstraction,
private cipherArchiveService: CipherArchiveService,
private policyService: PolicyService,
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private masterPasswordService: MasterPasswordServiceAbstraction,
) {}
async ngOnInit() {
@@ -241,6 +266,7 @@ export class VaultV2Component<C extends CipherViewLike>
)
.subscribe((canAccessPremium: boolean) => {
this.userHasPremiumAccess = canAccessPremium;
this.userHasPremium.set(canAccessPremium);
});
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
@@ -288,30 +314,40 @@ export class VaultV2Component<C extends CipherViewLike>
this.showingModal = false;
break;
case "copyUsername": {
if (this.cipher?.login?.username) {
this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username");
if (this.cipher()?.login?.username) {
this.copyValue(
this.cipher(),
this.cipher()?.login?.username,
"username",
"Username",
);
}
break;
}
case "copyPassword": {
if (this.cipher?.login?.password && this.cipher.viewPassword) {
this.copyValue(this.cipher, this.cipher.login.password, "password", "Password");
if (this.cipher()?.login?.password && this.cipher().viewPassword) {
this.copyValue(
this.cipher(),
this.cipher().login.password,
"password",
"Password",
);
await this.eventCollectionService
.collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id)
.collect(EventType.Cipher_ClientCopiedPassword, this.cipher().id)
.catch(() => {});
}
break;
}
case "copyTotp": {
if (
this.cipher?.login?.hasTotp &&
(this.cipher.organizationUseTotp || this.userHasPremiumAccess)
this.cipher()?.login?.hasTotp &&
(this.cipher()?.organizationUseTotp || this.userHasPremiumAccess)
) {
const value = await firstValueFrom(
this.totpService.getCode$(this.cipher.login.totp),
this.totpService.getCode$(this.cipher()?.login.totp),
).catch((): any => null);
if (value) {
this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP");
this.copyValue(this.cipher(), value.code, "verificationCodeTotp", "TOTP");
}
}
break;
@@ -337,19 +373,12 @@ export class VaultV2Component<C extends CipherViewLike>
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const authRequests = await firstValueFrom(
this.authRequestService.getLatestPendingAuthRequest$()!,
);
if (authRequests != null) {
this.messagingService.send("openLoginApproval", {
notificationId: authRequests.id,
});
}
this.activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
).catch((): any => null);
await this.sendOpenLoginApprovalMessage(this.activeUserId);
if (this.activeUserId) {
this.cipherService
.failedToDecryptCiphers$(this.activeUserId)
@@ -416,6 +445,7 @@ export class VaultV2Component<C extends CipherViewLike>
selectedOrganizationId: params.selectedOrganizationId,
myVaultOnly: params.myVaultOnly ?? false,
});
this.activeFilterSubject.next(this.activeFilter);
if (this.vaultItemsComponent) {
await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {});
}
@@ -440,7 +470,7 @@ export class VaultV2Component<C extends CipherViewLike>
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
this.cipher.set(cipher);
this.collections =
this.vaultFilterComponent?.collections?.fullList.filter((c) =>
cipher.collectionIds.includes(c.id),
@@ -679,7 +709,7 @@ export class VaultV2Component<C extends CipherViewLike>
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
this.cipher.set(cipher);
await this.buildFormConfig("edit");
if (!cipher.edit && this.config) {
this.config.mode = "partial-edit";
@@ -693,7 +723,7 @@ export class VaultV2Component<C extends CipherViewLike>
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
this.cipher.set(cipher);
await this.buildFormConfig("clone");
this.action = "clone";
await this.go().catch(() => {});
@@ -742,7 +772,7 @@ export class VaultV2Component<C extends CipherViewLike>
return;
}
this.addType = type || this.activeFilter.cipherType;
this.cipher = new CipherView();
this.cipher.set(new CipherView());
this.cipherId = null;
await this.buildFormConfig("add");
this.action = "add";
@@ -774,14 +804,14 @@ export class VaultV2Component<C extends CipherViewLike>
);
this.cipherId = cipher.id;
this.cipher = cipher;
this.cipher.set(cipher);
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});
}
async deleteCipher() {
this.cipherId = null;
this.cipher = null;
this.cipher.set(null);
this.action = null;
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});
@@ -796,7 +826,7 @@ export class VaultV2Component<C extends CipherViewLike>
async cancelCipher(cipher: CipherView) {
this.cipherId = cipher.id;
this.cipher = cipher;
this.cipher.set(cipher);
this.action = this.cipherId ? "view" : null;
await this.go().catch(() => {});
}
@@ -806,6 +836,7 @@ export class VaultV2Component<C extends CipherViewLike>
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
);
this.activeFilter = vaultFilter;
this.activeFilterSubject.next(vaultFilter);
await this.vaultItemsComponent
?.reload(
this.activeFilter.buildFilter(),
@@ -887,14 +918,16 @@ export class VaultV2Component<C extends CipherViewLike>
/** Refresh the current cipher object */
protected async refreshCurrentCipher() {
if (!this.cipher) {
if (!this.cipher()) {
return;
}
this.cipher = await firstValueFrom(
this.cipherService.cipherViews$(this.activeUserId!).pipe(
filter((c) => !!c),
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
this.cipher.set(
await firstValueFrom(
this.cipherService.cipherViews$(this.activeUserId!).pipe(
filter((c) => !!c),
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
),
),
);
}
@@ -1020,4 +1053,27 @@ export class VaultV2Component<C extends CipherViewLike>
}
return repromptResult;
}
/**
* Sends a message that will retrieve any pending auth requests from the server. If there are
* pending auth requests for this user, a LoginApprovalDialogComponent will open. If there
* are no pending auth requests, nothing happens (see AppComponent: "openLoginApproval").
*/
private async sendOpenLoginApprovalMessage(activeUserId: UserId) {
// This is a defensive check against a race condition where a user may have successfully logged
// in with no forceSetPasswordReason, but while the vault component is loading, a sync sets
// forceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission (see DefaultSyncService).
// This could potentially happen if an Admin upgrades the user's permissions as the user is logging
// in. In this rare case we do not want to send an "openLoginApproval" message.
//
// This also keeps parity with other usages of the "openLoginApproval" message. That is: don't send
// an "openLoginApproval" message if the user is required to set/change their password.
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
);
if (forceSetPasswordReason === ForceSetPasswordReason.None) {
// If there are pending auth requests for this user, a LoginApprovalDialogComponent will open
this.messagingService.send("openLoginApproval");
}
}
}

View File

@@ -5,7 +5,6 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -35,7 +34,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider: StateProvider,
collectionService: CollectionService,
accountService: AccountService,
configService: ConfigService,
) {
super(
organizationService,
@@ -46,7 +44,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider,
collectionService,
accountService,
configService,
);
}

View File

@@ -6,7 +6,6 @@ import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkConfirmRequest,
OrganizationUserBulkPublicKeyResponse,
OrganizationUserBulkResponse,
OrganizationUserService,
@@ -15,10 +14,8 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -54,7 +51,6 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
@@ -84,19 +80,9 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
} else {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organization.id,
request,
);
}
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -12,15 +12,10 @@ import {
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@@ -30,13 +25,9 @@ describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
@@ -46,10 +37,7 @@ describe("MemberActionsService", () => {
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = {
@@ -71,10 +59,7 @@ describe("MemberActionsService", () => {
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
keyService,
encryptService,
configService,
accountService,
organizationMetadataService,
);
});
@@ -242,8 +227,7 @@ describe("MemberActionsService", () => {
describe("confirmUser", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should confirm user using new flow when feature flag is enabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it("should confirm user", async () => {
organizationUserService.confirmUser.mockReturnValue(of(undefined));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
@@ -257,44 +241,7 @@ describe("MemberActionsService", () => {
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should confirm user using exising flow when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const mockOrgKey = mock<OrgKey>();
const mockOrgKeys = { [organizationId]: mockOrgKey };
keyService.orgKeys$.mockReturnValue(of(mockOrgKeys));
const mockEncryptedKey = new EncString("encrypted-key-data");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(keyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
organizationId,
userIdToManage,
expect.objectContaining({
key: "encrypted-key-data",
}),
);
});
it("should handle missing organization keys", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
keyService.orgKeys$.mockReturnValue(of({}));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain("Organization keys not found");
});
it("should handle confirm errors", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const errorMessage = "Confirm failed";
organizationUserService.confirmUser.mockImplementation(() => {
throw new Error(errorMessage);

View File

@@ -1,10 +1,9 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import {
@@ -12,14 +11,10 @@ import {
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@@ -38,15 +33,10 @@ export interface BulkActionResult {
@Injectable()
export class MemberActionsService {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
private accountService: AccountService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
@@ -128,37 +118,9 @@ export class MemberActionsService {
organization: Organization,
): Promise<MemberActionResult> {
try {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };

View File

@@ -30,7 +30,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import {
@@ -115,7 +114,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
@@ -131,7 +129,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);

View File

@@ -1,9 +1,8 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { of, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
@@ -16,9 +15,8 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
component = OrganizationDataOwnershipPolicyComponent;
display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.pipe(map((enabled) => !enabled));
// TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
return of(false);
}
}

View File

@@ -1,12 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } from "rxjs";
import { lastValueFrom } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
@@ -28,10 +25,6 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
}
}
@Component({

View File

@@ -14,8 +14,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
@@ -75,7 +73,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
) {}
@@ -132,10 +129,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
if (
this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent &&
(await this.isVNextEnabled())
) {
if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -154,14 +148,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
};
private async isVNextEnabled(): Promise<boolean> {
const isVNextFeatureEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
);
return isVNextFeatureEnabled;
}
private async handleStandardSubmission(): Promise<void> {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");

View File

@@ -59,9 +59,11 @@ import {
} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
@@ -483,6 +485,11 @@ const safeProviders: SafeProvider[] = [
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService],
}),
safeProvider({
provide: AuthRequestAnsweringService,
useClass: NoopAuthRequestAnsweringService,
deps: [],
}),
];
@NgModule({

View File

@@ -64,11 +64,11 @@
<p class="tw-mt-4 tw-max-w-96 tw-text-center">
{{ "gettingStartedWithBitwardenPart1" | i18n }}
<a bitLink href="https://bitwarden.com/help/learning-center/">
<a target="_blank" bitLink href="https://bitwarden.com/help/learning-center/">
{{ "gettingStartedWithBitwardenPart2" | i18n }}
</a>
{{ "and" | i18n }}
<a bitLink href="https://bitwarden.com/help">
<a target="_blank" bitLink href="https://bitwarden.com/help">
{{ "gettingStartedWithBitwardenPart3" | i18n }}
</a>
</p>

View File

@@ -3,7 +3,7 @@
{{ title }}
</span>
@if (cipherIsArchived) {
<span bitBadge bitDialogHeaderEnd> {{ "archiveNoun" | i18n }} </span>
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}
<div bitDialogContent #dialogContent>

View File

@@ -24,8 +24,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -111,11 +109,8 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
collectionTree$: Observable<TreeNode<CollectionFilter>> = combineLatest([
this.filteredCollections$,
this.memberOrganizations$,
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
]).pipe(
map(([collections, organizations, defaultCollectionsFlagEnabled]) =>
this.buildCollectionTree(collections, organizations, defaultCollectionsFlagEnabled),
),
map(([collections, organizations]) => this.buildCollectionTree(collections, organizations)),
);
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>> = this.buildCipherTypeTree();
@@ -133,7 +128,6 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected stateProvider: StateProvider,
protected collectionService: CollectionService,
protected accountService: AccountService,
protected configService: ConfigService,
) {}
async getCollectionNodeFromTree(id: string) {
@@ -241,18 +235,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected buildCollectionTree(
collections?: CollectionView[],
orgs?: Organization[],
defaultCollectionsFlagEnabled?: boolean,
): TreeNode<CollectionFilter> {
const headNode = this.getCollectionFilterHead();
if (!collections) {
return headNode;
}
const all: TreeNode<CollectionFilter>[] = [];
if (defaultCollectionsFlagEnabled) {
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
}
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
const groupedByOrg = this.collectionService.groupByOrganization(collections);
for (const group of groupedByOrg.values()) {

View File

@@ -47,7 +47,11 @@ export function createFilterFunction(
if (filter.type === "archive" && !CipherViewLikeUtils.isArchived(cipher)) {
return false;
}
if (filter.type !== "archive" && CipherViewLikeUtils.isArchived(cipher)) {
if (
filter.type !== "archive" &&
filter.type !== "trash" &&
CipherViewLikeUtils.isArchived(cipher)
) {
return false;
}
}

View File

@@ -3146,6 +3146,9 @@
"premiumSubscriptionEndedDesc": {
"message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault."
},
"itemRestored": {
"message": "Item has been restored"
},
"restartPremium": {
"message": "Restart Premium"
},
@@ -11613,6 +11616,9 @@
"unArchive": {
"message": "Unarchive"
},
"archived": {
"message": "Archived"
},
"unArchiveAndSave": {
"message": "Unarchive and save"
},

View File

@@ -1,30 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service";
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
describe("DefaultLoginApprovalDialogComponentService", () => {
let service: DefaultLoginApprovalDialogComponentService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DefaultLoginApprovalDialogComponentService],
});
service = TestBed.inject(DefaultLoginApprovalDialogComponentService);
});
it("is created successfully", () => {
expect(service).toBeTruthy();
});
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent;
const result = await service.showLoginRequestedAlertIfWindowNotVisible(
loginApprovalDialogComponent.email,
);
expect(result).toBeUndefined();
});
});

View File

@@ -1,14 +0,0 @@
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
/**
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
*/
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns
*/
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
return;
}
}

View File

@@ -1,3 +1 @@
export * from "./login-approval-dialog.component";
export * from "./login-approval-dialog-component.service.abstraction";
export * from "./default-login-approval-dialog-component.service";

View File

@@ -1,9 +0,0 @@
/**
* Abstraction for the LoginApprovalDialogComponent service.
*/
export abstract class LoginApprovalDialogComponentServiceAbstraction {
/**
* Shows a login requested alert if the window is not visible.
*/
abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise<void>;
}

View File

@@ -16,7 +16,6 @@ import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
describe("LoginApprovalDialogComponent", () => {
@@ -69,10 +68,6 @@ describe("LoginApprovalDialogComponent", () => {
{ provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService },
{ provide: ValidationService, useValue: validationService },
{
provide: LoginApprovalDialogComponentServiceAbstraction,
useValue: mock<LoginApprovalDialogComponentServiceAbstraction>(),
},
],
}).compileComponents();

View File

@@ -24,8 +24,6 @@ import {
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
const RequestTimeOut = 60000 * 15; // 15 Minutes
const RequestTimeUpdate = 60000 * 5; // 5 Minutes
@@ -57,7 +55,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
private devicesService: DevicesServiceAbstraction,
private dialogRef: DialogRef,
private i18nService: I18nService,
private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction,
private logService: LogService,
private toastService: ToastService,
private validationService: ValidationService,
@@ -113,10 +110,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
this.updateTimeText();
}, RequestTimeUpdate);
await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible(
this.email,
);
this.loading = false;
}

View File

@@ -94,7 +94,7 @@ import {
InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
@@ -112,7 +112,7 @@ import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/aut
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
@@ -397,8 +397,6 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service";
import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction";
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
@@ -1043,9 +1041,15 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: AuthRequestAnsweringServiceAbstraction,
useClass: NoopAuthRequestAnsweringService,
deps: [],
provide: AuthRequestAnsweringService,
useClass: DefaultAuthRequestAnsweringService,
deps: [
AccountServiceAbstraction,
AuthServiceAbstraction,
MasterPasswordServiceAbstraction,
MessagingServiceAbstraction,
PendingAuthRequestsStateService,
],
}),
safeProvider({
provide: ServerNotificationsService,
@@ -1063,7 +1067,7 @@ const safeProviders: SafeProvider[] = [
SignalRConnectionService,
AuthServiceAbstraction,
WebPushConnectionService,
AuthRequestAnsweringServiceAbstraction,
AuthRequestAnsweringService,
ConfigService,
InternalPolicyService,
],
@@ -1669,11 +1673,6 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSendPasswordService,
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
provide: LoginApprovalDialogComponentServiceAbstraction,
useClass: DefaultLoginApprovalDialogComponentService,
deps: [],
}),
safeProvider({
provide: LoginDecryptionOptionsService,
useClass: DefaultLoginDecryptionOptionsService,

View File

@@ -14,8 +14,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
@@ -45,7 +43,6 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
protected policyService: PolicyService,
protected stateProvider: StateProvider,
protected accountService: AccountService,
protected configService: ConfigService,
protected i18nService: I18nService,
) {}
@@ -116,18 +113,13 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
),
);
const orgs = await this.buildOrganizations();
const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.CreateDefaultLocation,
);
let collections =
organizationId == null
? storedCollections
: storedCollections.filter((c) => c.organizationId === organizationId);
if (defaulCollectionsFlagEnabled) {
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
}
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
const nestedCollections = await this.collectionService.getAllNested(collections);
return new DynamicTreeNode<CollectionView>({

View File

@@ -1,10 +1,10 @@
export type LogoutReason =
| "invalidGrantError"
| "vaultTimeout"
| "invalidSecurityStamp"
| "logoutNotification"
| "keyConnectorError"
| "sessionExpired"
| "accessTokenUnableToBeDecrypted"
| "accountDeleted"
| "invalidAccessToken"
| "invalidSecurityStamp"
| "keyConnectorError"
| "logoutNotification"
| "refreshTokenSecureStorageRetrievalFailure"
| "accountDeleted";
| "sessionExpired"
| "vaultTimeout";

View File

@@ -446,6 +446,13 @@ export abstract class ApiService {
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
abstract postSetupPayment(): Promise<string>;
/**
* Retrieves the bearer access token for the user.
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token
* and persists the refresh token to state before returning it.
* @param userId The user for whom we're retrieving the access token
* @returns The access token, or an Error if no access token exists.
*/
abstract getActiveBearerToken(userId: UserId): Promise<string>;
abstract fetch(request: Request): Promise<Response>;
abstract nativeFetch(request: Request): Promise<Response>;

View File

@@ -1,7 +1,6 @@
# Auth Request Answering Service
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
be acted on when the user loads up a client. Currently only implemented with the browser client.
This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client.
See diagram for the high level picture of how this is wired up.

View File

@@ -1,30 +1,50 @@
import { Observable } from "rxjs";
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
export abstract class AuthRequestAnsweringServiceAbstraction {
export abstract class AuthRequestAnsweringService {
/**
* Tries to either display the dialog for the user or will preserve its data and show it at a
* later time. Even in the event the dialog is shown immediately, this will write to global state
* so that even if someone closes a window or a popup and comes back, it could be processed later.
* Only way to clear out the global state is to respond to the auth request.
* - Implemented on Extension and Desktop.
*
* Currently, this is only implemented for browser extension.
*
* @param userId The UserId that the auth request is for.
* @param authRequestUserId The UserId that the auth request is for.
* @param authRequestId The id of the auth request that is to be processed.
*/
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
abstract receivedPendingAuthRequest?(
authRequestUserId: UserId,
authRequestId: string,
): Promise<void>;
/**
* When a system notification is clicked, this function is used to process that event.
* Confirms whether or not the user meets the conditions required to show them an
* approval dialog immediately.
*
* @param authRequestUserId the UserId that the auth request is for.
* @returns boolean stating whether or not the user meets conditions
*/
abstract activeUserMeetsConditionsToShowApprovalDialog(
authRequestUserId: UserId,
): Promise<boolean>;
/**
* Sets up listeners for scenarios where the user unlocks and we want to process
* any pending auth requests in state.
*
* @param destroy$ The destroy$ observable from the caller
*/
abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void;
/**
* When a system notification is clicked, this method is used to process that event.
* - Implemented on Extension only.
* - Desktop does not implement this method because click handling is already setup in
* electron-main-messaging.service.ts.
*
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
*/
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
/**
* Process notifications that have been received but didn't meet the conditions to display the
* approval dialog.
*/
abstract processPendingAuthRequests(): Promise<void>;
}

View File

@@ -1,142 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
describe("AuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let actionService: MockProxy<ActionsService>;
let authService: MockProxy<AuthService>;
let i18nService: MockProxy<I18nService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let systemNotificationsService: MockProxy<SystemNotificationsService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
beforeEach(() => {
accountService = mock<AccountService>();
actionService = mock<ActionsService>();
authService = mock<AuthService>();
i18nService = mock<I18nService>();
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
platformUtilsService = mock<PlatformUtilsService>();
systemNotificationsService = mock<SystemNotificationsService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
const accountInfo = mockAccountInfoWith({
email: "user@example.com",
name: "User",
});
accountService.activeAccount$ = of({
id: userId,
...accountInfo,
});
accountService.accounts$ = of({
[userId]: accountInfo,
});
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
of(ForceSetPasswordReason.None),
);
platformUtilsService.isPopupOpen.mockResolvedValue(false);
i18nService.t.mockImplementation(
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
);
systemNotificationsService.create.mockResolvedValue("notif-id");
sut = new AuthRequestAnsweringService(
accountService,
actionService,
authService,
i18nService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
platformUtilsService,
systemNotificationsService,
);
});
describe("handleAuthRequestNotificationClicked", () => {
it("clears notification and opens popup when notification body is clicked", async () => {
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.NotificationButton,
};
await sut.handleAuthRequestNotificationClicked(event);
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
});
it("does nothing when an optional button is clicked", async () => {
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.FirstOptionalButton,
};
await sut.handleAuthRequestNotificationClicked(event);
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
expect(actionService.openPopup).not.toHaveBeenCalled();
});
});
describe("receivedPendingAuthRequest", () => {
const authRequestId = "req-abc";
it("creates a system notification when popup is not open", async () => {
platformUtilsService.isPopupOpen.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
await sut.receivedPendingAuthRequest(userId, authRequestId);
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
expect(systemNotificationsService.create).toHaveBeenCalledWith({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
title: "accountAccessRequested",
body: "confirmAccessAttempt:user@example.com",
buttons: [],
});
});
it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => {
platformUtilsService.isPopupOpen.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
of(ForceSetPasswordReason.None),
);
await sut.receivedPendingAuthRequest(userId, authRequestId);
expect(systemNotificationsService.create).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,111 +0,0 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor(
private readonly accountService: AccountService,
private readonly actionService: ActionsService,
private readonly authService: AuthService,
private readonly i18nService: I18nService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
private readonly messagingService: MessagingService,
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly systemNotificationsService: SystemNotificationsService,
) {}
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
const activeUserId: UserId | null = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
const popupOpen = await this.platformUtilsService.isPopupOpen();
// Always persist the pending marker for this user to global state.
await this.pendingAuthRequestsState.add(userId);
// These are the conditions we are looking for to know if the extension is in a state to show
// the approval dialog.
const userIsAvailableToReceiveAuthRequest =
popupOpen &&
authStatus === AuthenticationStatus.Unlocked &&
activeUserId === userId &&
forceSetPasswordReason === ForceSetPasswordReason.None;
if (!userIsAvailableToReceiveAuthRequest) {
// Get the user's email to include in the system notification
const accounts = await firstValueFrom(this.accountService.accounts$);
const emailForUser = accounts[userId].email;
await this.systemNotificationsService.create({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
title: this.i18nService.t("accountAccessRequested"),
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
buttons: [],
});
return;
}
// Popup is open and conditions are met; open dialog immediately for this request
this.messagingService.send("openLoginApproval");
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
await this.systemNotificationsService.clear({
id: `${event.id}`,
});
await this.actionService.openPopup();
}
}
async processPendingAuthRequests(): Promise<void> {
// Prune any stale pending requests (older than 15 minutes)
// This comes from GlobalSettings.cs
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
const fifteenMinutesMs = 15 * 60 * 1000;
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
if (pendingAuthRequestsInState.length > 0) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
(e) => e.userId === activeUserId,
);
if (pendingAuthRequestsForActiveUser) {
this.messagingService.send("openLoginApproval");
}
}
}
}

View File

@@ -0,0 +1,444 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of, Subject } from "rxjs";
import { Account, 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 { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import {
ButtonLocation,
SystemNotificationEvent,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
describe("DefaultAuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let authService: MockProxy<AuthService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
const userAccountInfo = mockAccountInfoWith({
name: "User",
email: "user@example.com",
});
const userAccount: Account = {
id: userId,
...userAccountInfo,
};
const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId;
const otherUserAccountInfo = mockAccountInfoWith({
name: "Other",
email: "other@example.com",
});
const otherUserAccount: Account = {
id: otherUserId,
...otherUserAccountInfo,
};
beforeEach(() => {
accountService = mock<AccountService>();
authService = mock<AuthService>();
masterPasswordService = {
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
};
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of(userAccount);
accountService.accounts$ = of({
[userId]: userAccountInfo,
[otherUserId]: otherUserAccountInfo,
});
sut = new DefaultAuthRequestAnsweringService(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
);
});
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
it("should return false if there is no active user", async () => {
// Arrange
accountService.activeAccount$ = of(null);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is not the intended recipient of the auth request", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is not unlocked", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(true);
});
});
describe("setupUnlockListenersForProcessingAuthRequests()", () => {
let destroy$: Subject<void>;
let activeAccount$: BehaviorSubject<Account>;
let activeAccountStatus$: BehaviorSubject<AuthenticationStatus>;
let authStatusForSubjects: Map<UserId, BehaviorSubject<AuthenticationStatus>>;
let pendingRequestMarkers: PendingAuthUserMarker[];
beforeEach(() => {
destroy$ = new Subject<void>();
activeAccount$ = new BehaviorSubject(userAccount);
activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked);
authStatusForSubjects = new Map();
pendingRequestMarkers = [];
accountService.activeAccount$ = activeAccount$;
authService.activeAccountStatus$ = activeAccountStatus$;
authService.authStatusFor$.mockImplementation((id: UserId) => {
if (!authStatusForSubjects.has(id)) {
authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked));
}
return authStatusForSubjects.get(id)!;
});
pendingAuthRequestsState.getAll$.mockReturnValue(of([]));
});
afterEach(() => {
destroy$.next();
destroy$.complete();
});
describe("active account switching", () => {
it("should process pending auth requests when switching to an unlocked user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Simulate account switching to an Unlocked account
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when switching to a locked user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when switching to a logged out user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when active account becomes null", async () => {
// Arrange
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(null);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should handle multiple user switches correctly", async () => {
// Arrange
authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked));
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Switch to unlocked user (should trigger)
activeAccount$.next(otherUserAccount);
await new Promise((resolve) => setTimeout(resolve, 0));
// Switch to locked user (should NOT trigger)
activeAccount$.next(userAccount);
await new Promise((resolve) => setTimeout(resolve, 0));
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
});
it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
describe("authentication status transitions", () => {
it("should process pending auth requests when active account transitions to Unlocked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
await new Promise((resolve) => setTimeout(resolve, 0));
// Clear any calls from the initial trigger (from null -> Unlocked)
messagingService.send.mockClear();
activeAccountStatus$.next(AuthenticationStatus.Locked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when staying in Unlocked status", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
await new Promise((resolve) => setTimeout(resolve, 0));
// Clear any calls from the initial trigger (from null -> Unlocked)
messagingService.send.mockClear();
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should handle multiple status transitions correctly", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Transition to Unlocked (should trigger)
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Transition to Locked (should NOT trigger)
activeAccountStatus$.next(AuthenticationStatus.Locked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Transition back to Unlocked (should trigger again)
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(2);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
describe("subscription cleanup", () => {
it("should stop processing when destroy$ emits", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Emit destroy signal
destroy$.next();
// Try to trigger processing after cleanup
activeAccount$.next(otherUserAccount);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
});
describe("handleAuthRequestNotificationClicked()", () => {
it("should throw an error", async () => {
// Arrange
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.NotificationButton,
};
// Act
const promise = sut.handleAuthRequestNotificationClicked(event);
// Assert
await expect(promise).rejects.toThrow(
"handleAuthRequestNotificationClicked() not implemented for this client",
);
});
});
});

View File

@@ -0,0 +1,140 @@
import {
distinctUntilChanged,
filter,
firstValueFrom,
map,
Observable,
pairwise,
startWith,
switchMap,
take,
takeUntil,
tap,
} from "rxjs";
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 { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService {
constructor(
protected readonly accountService: AccountService,
protected readonly authService: AuthService,
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
protected readonly messagingService: MessagingService,
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
) {}
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
// If the active user is not the intended recipient of the auth request, return false
const activeUserId: UserId | null = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId !== authRequestUserId) {
return false;
}
// If the active user is not unlocked, return false
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
return false;
}
// If the active user is required to set/change their master password, return false
// Note that by this point we know that the authRequestUserId is the active UserId (see check above)
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(authRequestUserId),
);
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
return false;
}
// User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password
return true;
}
setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void {
// When account switching to a user who is Unlocked, process any pending auth requests.
this.accountService.activeAccount$
.pipe(
map((a) => a?.id), // Extract active userId
distinctUntilChanged(), // Only when userId actually changes
filter((userId) => userId != null), // Require a valid userId
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
tap(() => {
void this.processPendingAuthRequests();
}),
takeUntil(destroy$),
)
.subscribe();
// When the active account transitions TO Unlocked, process any pending auth requests.
this.authService.activeAccountStatus$
.pipe(
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
pairwise(), // Compare previous and current statuses
filter(
([prev, curr]) =>
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
),
takeUntil(destroy$),
)
.subscribe(() => {
void this.processPendingAuthRequests();
});
}
/**
* Process notifications that have been received but didn't meet the conditions to display the
* approval dialog.
*/
private async processPendingAuthRequests(): Promise<void> {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// Only continue if the active user is not required to set/change their master password
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
);
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
return;
}
// Prune any stale pending requests (older than 15 minutes)
// This comes from GlobalSettings.cs
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
const fifteenMinutesMs = 15 * 60 * 1000;
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
if (pendingAuthRequestsInState.length > 0) {
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
(e) => e.userId === activeUserId,
);
if (pendingAuthRequestsForActiveUser) {
this.messagingService.send("openLoginApproval");
}
}
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
}
}

View File

@@ -1,14 +1,22 @@
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor() {}
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService {
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
throw new Error(
"activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client",
);
}
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
setupUnlockListenersForProcessingAuthRequests(): void {
throw new Error(
"setupUnlockListenersForProcessingAuthRequests() not implemented for this client",
);
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
async processPendingAuthRequests(): Promise<void> {}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
}
}

View File

@@ -11,7 +11,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
@@ -98,7 +97,6 @@ const FALSE = false as boolean;
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,

View File

@@ -4,7 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { mockAccountInfoWith } from "../../../../spec";
import { AccountService } from "../../../auth/abstractions/account.service";
@@ -33,7 +33,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
let authService: MockProxy<AuthService>;
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
let configService: MockProxy<ConfigService>;
let policyService: MockProxy<InternalPolicyService>;
@@ -127,7 +127,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
return webPushSupportStatusByUser.get(userId)!.asObservable();
});
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
policyService = mock<InternalPolicyService>();
@@ -270,13 +270,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
// allow async queue to drain
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
notificationId: "auth-id-2",
});
// When authRequestAnsweringService.receivedPendingAuthRequest exists (Extension/Desktop),
// only that method is called. messagingService.send is only called for Web (NoopAuthRequestAnsweringService).
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
mockUserId2,
"auth-id-2",
);
expect(messagingService.send).not.toHaveBeenCalled();
subscription.unsubscribe();
});

View File

@@ -6,7 +6,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj
import { LogoutReason } from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { awaitAsync, mockAccountInfoWith } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
@@ -42,7 +42,7 @@ describe("NotificationsService", () => {
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
let authService: MockProxy<AuthService>;
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
let configService: MockProxy<ConfigService>;
let policyService: MockProxy<InternalPolicyService>;
@@ -72,7 +72,7 @@ describe("NotificationsService", () => {
signalRNotificationConnectionService = mock<SignalRConnectionService>();
authService = mock<AuthService>();
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
configService = mock<ConfigService>();
policyService = mock<InternalPolicyService>();
@@ -471,5 +471,41 @@ describe("NotificationsService", () => {
);
});
});
describe("NotificationType.AuthRequest", () => {
it("should call receivedPendingAuthRequest when it exists (Extension/Desktop)", async () => {
authRequestAnsweringService.receivedPendingAuthRequest!.mockResolvedValue(undefined as any);
const notification = new NotificationResponse({
type: NotificationType.AuthRequest,
payload: { userId: mockUser1, id: "auth-request-123" },
contextId: "different-app-id",
});
await sut["processNotification"](notification, mockUser1);
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
mockUser1,
"auth-request-123",
);
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should call messagingService.send when receivedPendingAuthRequest does not exist (Web)", async () => {
authRequestAnsweringService.receivedPendingAuthRequest = undefined as any;
const notification = new NotificationResponse({
type: NotificationType.AuthRequest,
payload: { userId: mockUser1, id: "auth-request-456" },
contextId: "different-app-id",
});
await sut["processNotification"](notification, mockUser1);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
notificationId: "auth-request-456",
});
});
});
});
});

View File

@@ -17,7 +17,7 @@ import {
import { LogoutReason } from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { trackedMerge } from "@bitwarden/common/platform/misc";
@@ -67,7 +67,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
private readonly signalRConnectionService: SignalRConnectionService,
private readonly authService: AuthService,
private readonly webPushConnectionService: WebPushConnectionService,
private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
private readonly authRequestAnsweringService: AuthRequestAnsweringService,
private readonly configService: ConfigService,
private readonly policyService: InternalPolicyService,
) {
@@ -250,26 +250,28 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
case NotificationType.SyncSendDelete:
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break;
case NotificationType.AuthRequest:
await this.authRequestAnsweringService.receivedPendingAuthRequest(
notification.payload.userId,
notification.payload.id,
);
/**
* This call is necessary for Desktop, which for the time being uses a noop for the
* authRequestAnsweringService.receivedPendingAuthRequest() call just above. Desktop
* will eventually use the new AuthRequestAnsweringService, at which point we can remove
* this second call.
*
* The Extension AppComponent has logic (see processingPendingAuth) that only allows one
* pending auth request to process at a time, so this second call will not cause any
* duplicate processing conflicts on Extension.
*/
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
case NotificationType.AuthRequest: {
// Only Extension and Desktop implement the AuthRequestAnsweringService
if (this.authRequestAnsweringService.receivedPendingAuthRequest) {
try {
await this.authRequestAnsweringService.receivedPendingAuthRequest(
notification.payload.userId,
notification.payload.id,
);
} catch (error) {
this.logService.error(`Failed to process auth request notification: ${error}`);
}
} else {
// This call is necessary for Web, which uses a NoopAuthRequestAnsweringService
// that does not have a receivedPendingAuthRequest() method
this.messagingService.send("openLoginApproval", {
// Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
// This will only matter if the user is on the /device-management screen when the auth request is received.
notificationId: notification.payload.id,
});
}
break;
}
case NotificationType.SyncOrganizationStatusChanged:
await this.syncService.fullSync(true);
break;

View File

@@ -449,4 +449,798 @@ describe("ApiService", () => {
).rejects.toThrow(InsecureUrlNotAllowedError);
expect(nativeFetch).not.toHaveBeenCalled();
});
describe("When a 401 Unauthorized status is received", () => {
it("retries request with refreshed token when initial request with access token returns 401", async () => {
// This test verifies the 401 retry flow:
// 1. Initial request with valid token returns 401 (token expired server-side)
// 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh
// 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken
// 4. refreshToken makes an HTTP call to /connect/token to get new tokens
// 5. setTokens is called to store the new tokens, returning the refreshed access token
// 6. Request is retried with the refreshed token and succeeds
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
// First call (initial request): token doesn't need refresh yet
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with expired token returns 401
if (callCount === 1) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request
if (callCount === 2 && request.url.includes("identity")) {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with refreshed token succeeds
if (callCount === 3) {
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: "success" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
const response = await sut.send("GET", "/something", null, true, true, null, null);
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(response).toEqual({ data: "success" });
});
it("does not retry when request has no access token and returns 401", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
// Should only be called once (no retry)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("does not retry when request returns non-401 error", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 400,
json: () => Promise.resolve({ message: "Bad Request" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Bad Request" });
// Should only be called once (no retry for non-401 errors)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("does not attempt to log out unauthenticated user", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
expect(logoutCallback).not.toHaveBeenCalled();
});
it("does not retry when hasResponse is false", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
// When hasResponse is false, the method should throw even though no retry happens
await expect(
async () => await sut.send("POST", "/something", null, true, false, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
// Should only be called once (no retry when hasResponse is false)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("uses original user token for retry even if active user changes between requests", async () => {
// Setup: Initial request is for testActiveUser, but during the retry, the active user switches
// to testInactiveUser. The retry should still use testActiveUser's refreshed token.
let activeUserId = testActiveUser;
// Mock accountService to return different active users based on when it's called
accountService.activeAccount$ = of({
id: activeUserId,
email: "user1@example.com",
emailVerified: true,
name: "Test Name",
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue(
of({
getApiUrl: () => "https://inactive.example.com",
getIdentityUrl: () => "https://identity.inactive.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken
.calledWith(testActiveUser)
.mockResolvedValue("active_access_token");
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken
.calledWith(testActiveUser)
.mockResolvedValue("active_refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("active_new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"active_new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"active_new_refresh_token",
)
.mockResolvedValue({ accessToken: "active_new_access_token" });
// Mock tokens for inactive user (should NOT be used)
tokenService.getAccessToken
.calledWith(testInactiveUser)
.mockResolvedValue("inactive_access_token");
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with active user's token returns 401
if (callCount === 1) {
expect(request.url).toBe("https://example.com/something");
expect(request.headers.get("Authorization")).toBe("Bearer active_access_token");
// After the 401, simulate active user changing
activeUserId = testInactiveUser;
accountService.activeAccount$ = of({
id: testInactiveUser,
email: "user2@example.com",
emailVerified: true,
name: "Inactive User",
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request for ORIGINAL user (testActiveUser)
if (callCount === 2 && request.url.includes("identity")) {
expect(request.url).toContain("identity.example.com");
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "active_new_access_token",
token_type: "Bearer",
refresh_token: "active_new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token
if (callCount === 3) {
expect(request.url).toBe("https://example.com/something");
expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token");
// Verify we're NOT using the inactive user's endpoint
expect(request.url).not.toContain("inactive");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: "success with original user" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
// Explicitly pass testActiveUser to ensure the request is for that specific user
const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null);
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(response).toEqual({ data: "success with original user" });
// Verify that inactive user's token was never requested
expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled();
});
it("throws error when retry also returns 401", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
// First call (initial request): token doesn't need refresh yet
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with expired token returns 401
if (callCount === 1) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request
if (callCount === 2 && request.url.includes("identity")) {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with refreshed token still returns 401 (user no longer has permission)
if (callCount === 3) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Still Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error("Unexpected call");
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Still Unauthorized" });
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
});
it("handles concurrent requests that both receive 401 and share token refresh", async () => {
// This test verifies the race condition scenario:
// 1. Request A starts with valid token
// 2. Request B starts with valid token
// 3. Request A gets 401, triggers refresh
// 4. Request B gets 401 while A is refreshing
// 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache)
// 6. Both requests retry with the new token
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
// First two calls: token doesn't need refresh yet
// Subsequent calls: token needs refresh
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false) // Request A initial
.mockResolvedValueOnce(false) // Request B initial
.mockResolvedValue(true); // Both retries after 401
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let apiRequestCount = 0;
let refreshRequestCount = 0;
nativeFetch.mockImplementation((request) => {
if (request.url.includes("identity")) {
refreshRequestCount++;
// Simulate slow token refresh to expose race condition
return new Promise((resolve) =>
setTimeout(
() =>
resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response),
100,
),
);
}
apiRequestCount++;
const currentCall = apiRequestCount;
// First two calls (Request A and B initial attempts): both return 401
if (currentCall === 1 || currentCall === 2) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Third and fourth calls (retries after refresh): both succeed
if (currentCall === 3 || currentCall === 4) {
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: `success-${currentCall}` }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
// Make two concurrent requests
const [responseA, responseB] = await Promise.all([
sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null),
sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null),
]);
// Both requests should succeed
expect(responseA).toMatchObject({ data: expect.stringContaining("success") });
expect(responseB).toMatchObject({ data: expect.stringContaining("success") });
// Verify only ONE token refresh was made (they shared the refresh)
expect(refreshRequestCount).toBe(1);
// Verify the total number of API requests: 2 initial + 2 retries = 4
expect(apiRequestCount).toBe(4);
// Verify setTokens was only called once
expect(tokenService.setTokens).toHaveBeenCalledTimes(1);
});
});
describe("When 403 Forbidden response is received from API request", () => {
it("logs out the authenticated user", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: "Forbidden" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Forbidden" });
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
});
it("does not attempt to log out unauthenticated user", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: "Forbidden" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Forbidden" });
expect(logoutCallback).not.toHaveBeenCalled();
});
});
});

View File

@@ -74,7 +74,7 @@ import { BillingHistoryResponse } from "../billing/models/response/billing-histo
import { PaymentResponse } from "../billing/models/response/payment.response";
import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { ClientType, DeviceType } from "../enums";
import { ClientType, DeviceType, HttpStatusCode } from "../enums";
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
@@ -1252,8 +1252,8 @@ export class ApiService implements ApiServiceAbstraction {
}),
);
if (response.status !== 200) {
const error = await this.handleError(response, false, true);
if (response.status !== HttpStatusCode.Ok) {
const error = await this.handleApiRequestError(response, true);
return Promise.reject(error);
}
@@ -1283,8 +1283,8 @@ export class ApiService implements ApiServiceAbstraction {
}),
);
if (response.status !== 200) {
const error = await this.handleError(response, false, true);
if (response.status !== HttpStatusCode.Ok) {
const error = await this.handleApiRequestError(response, true);
return Promise.reject(error);
}
}
@@ -1301,14 +1301,12 @@ export class ApiService implements ApiServiceAbstraction {
}),
);
if (response.status !== 200) {
const error = await this.handleError(response, false, true);
if (response.status !== HttpStatusCode.Ok) {
const error = await this.handleApiRequestError(response, true);
return Promise.reject(error);
}
}
// Helpers
async getActiveBearerToken(userId: UserId): Promise<string> {
let accessToken = await this.tokenService.getAccessToken(userId);
if (await this.tokenService.tokenNeedsRefresh(userId)) {
@@ -1370,7 +1368,7 @@ export class ApiService implements ApiServiceAbstraction {
const body = await response.json();
return new SsoPreValidateResponse(body);
} else {
const error = await this.handleError(response, false, true);
const error = await this.handleApiRequestError(response, false);
return Promise.reject(error);
}
}
@@ -1525,7 +1523,7 @@ export class ApiService implements ApiServiceAbstraction {
);
return refreshedTokens.accessToken;
} else {
const error = await this.handleError(response, true, true);
const error = await this.handleTokenRefreshRequestError(response);
return Promise.reject(error);
}
}
@@ -1580,6 +1578,89 @@ export class ApiService implements ApiServiceAbstraction {
apiUrl?: string | null,
alterHeaders?: (headers: Headers) => void,
): Promise<any> {
// We assume that if there is a UserId making the request, it is also an authenticated
// request and we will attempt to add an access token to the request.
const userIdMakingRequest = await this.getUserIdMakingRequest(authedOrUserId);
const environment = await firstValueFrom(
userIdMakingRequest == null
? this.environmentService.environment$
: this.environmentService.getEnvironment$(userIdMakingRequest),
);
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? environment.getApiUrl() : apiUrl;
const requestUrl = await this.buildSafeApiRequestUrl(apiUrl, path);
let request = await this.buildRequest(
method,
userIdMakingRequest,
environment,
hasResponse,
body,
alterHeaders,
);
let response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
// First, check to see if we were making an authenticated request and received an Unauthorized (401)
// response. This could mean that we attempted to make a request with an expired access token.
// If so, attempt to refresh the token and try again.
if (
hasResponse &&
userIdMakingRequest != null &&
response.status === HttpStatusCode.Unauthorized
) {
this.logService.warning(
"Unauthorized response received for request to " + path + ". Attempting request again.",
);
request = await this.buildRequest(
method,
userIdMakingRequest,
environment,
hasResponse,
body,
alterHeaders,
);
response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
}
// At this point we are processing either the initial response or the response for the retry with the refreshed
// access token.
const responseType = response.headers.get("content-type");
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) {
const responseJson = await response.json();
return responseJson;
} else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) {
return await response.text();
} else if (
response.status !== HttpStatusCode.Ok &&
response.status !== HttpStatusCode.NoContent
) {
const error = await this.handleApiRequestError(response, userIdMakingRequest != null);
return Promise.reject(error);
}
}
private buildSafeApiRequestUrl(apiUrl: string, path: string): string {
const pathParts = path.split("?");
// Check for path traversal patterns from any URL.
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
if (isInvalidUrl) {
throw new Error("The request URL contains dangerous patterns.");
}
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
return requestUrl;
}
private async getUserIdMakingRequest(authedOrUserId: UserId | boolean): Promise<UserId> {
if (authedOrUserId == null) {
throw new Error("A user id was given but it was null, cannot complete API request.");
}
@@ -1591,29 +1672,19 @@ export class ApiService implements ApiServiceAbstraction {
} else if (typeof authedOrUserId === "string") {
userId = authedOrUserId;
}
return userId;
}
const env = await firstValueFrom(
userId == null
? this.environmentService.environment$
: this.environmentService.getEnvironment$(userId),
);
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
const pathParts = path.split("?");
// Check for path traversal patterns from any URL.
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
if (isInvalidUrl) {
throw new Error("The request URL contains dangerous patterns.");
}
// Prevent directory traversal from malicious paths
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
private async buildRequest(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
userForAccessToken: UserId | null,
environment: Environment,
hasResponse: boolean,
body: string,
alterHeaders?: (headers: Headers) => void,
): Promise<RequestInit> {
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
userId,
userForAccessToken,
hasResponse,
body,
alterHeaders,
@@ -1621,29 +1692,17 @@ export class ApiService implements ApiServiceAbstraction {
const requestInit: RequestInit = {
cache: "no-store",
credentials: await this.getCredentials(env),
credentials: await this.getCredentials(environment),
method: method,
};
requestInit.headers = requestHeaders;
requestInit.body = requestBody;
const response = await this.fetch(this.httpOperations.createRequest(requestUrl, requestInit));
const responseType = response.headers.get("content-type");
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
if (hasResponse && response.status === 200 && responseIsJson) {
const responseJson = await response.json();
return responseJson;
} else if (hasResponse && response.status === 200 && responseIsCsv) {
return await response.text();
} else if (response.status !== 200 && response.status !== 204) {
const error = await this.handleError(response, false, userId != null);
return Promise.reject(error);
}
return requestInit;
}
private async buildHeadersAndBody(
userToAuthenticate: UserId | null,
userForAccessToken: UserId | null,
hasResponse: boolean,
body: any,
alterHeaders: (headers: Headers) => void,
@@ -1665,8 +1724,8 @@ export class ApiService implements ApiServiceAbstraction {
if (alterHeaders != null) {
alterHeaders(headers);
}
if (userToAuthenticate != null) {
const authHeader = await this.getActiveBearerToken(userToAuthenticate);
if (userForAccessToken != null) {
const authHeader = await this.getActiveBearerToken(userForAccessToken);
headers.set("Authorization", "Bearer " + authHeader);
} else {
// For unauthenticated requests, we need to tell the server what the device is for flag targeting,
@@ -1692,32 +1751,59 @@ export class ApiService implements ApiServiceAbstraction {
return [headers, requestBody];
}
private async handleError(
/**
* Handle an error response from a request to the Bitwarden API.
* If the request is made with an access token (aka the user is authenticated),
* and we receive a 401 or 403 response, we will log the user out, as this indicates
* that the access token used on the request is either expired or does not have the appropriate permissions.
* It is unlikely that it is expired, as we attempt to refresh the token on initial failure.
* @param response The response from the API request
* @param userIsAuthenticated A boolean indicating whether this is an authenticated request.
* @returns An ErrorResponse with a message based on the response status.
*/
private async handleApiRequestError(
response: Response,
tokenError: boolean,
authed: boolean,
userIsAuthenticated: boolean,
): Promise<ErrorResponse> {
if (
userIsAuthenticated &&
(response.status === HttpStatusCode.Unauthorized ||
response.status === HttpStatusCode.Forbidden)
) {
await this.logoutCallback("invalidAccessToken");
}
const responseJson = await this.getJsonResponse(response);
return new ErrorResponse(responseJson, response.status);
}
/**
* Handle an error response when trying to refresh an access token.
* If the error indicates that the user's session has expired, it will log the user out.
* @param response The response from the token refresh request.
* @returns An ErrorResponse with a message based on the response status.
*/
private async handleTokenRefreshRequestError(response: Response): Promise<ErrorResponse> {
const responseJson = await this.getJsonResponse(response);
// IdentityServer will return an invalid_grant response if the refresh token has expired.
// This means that the user's session has expired, and they need to log out.
// We issue the logoutCallback() to log the user out through messaging.
if (response.status === HttpStatusCode.BadRequest && responseJson?.error === "invalid_grant") {
await this.logoutCallback("sessionExpired");
}
return new ErrorResponse(responseJson, response.status, true);
}
private async getJsonResponse(response: Response): Promise<any> {
let responseJson: any = null;
if (this.isJsonResponse(response)) {
responseJson = await response.json();
} else if (this.isTextPlainResponse(response)) {
responseJson = { Message: await response.text() };
}
if (authed) {
if (
response.status === 401 ||
response.status === 403 ||
(tokenError &&
response.status === 400 &&
responseJson != null &&
responseJson.error === "invalid_grant")
) {
await this.logoutCallback("invalidGrantError");
}
}
return new ErrorResponse(responseJson, response.status, tokenError);
return responseJson;
}
private qsStringify(params: any): string {

View File

@@ -1109,7 +1109,7 @@ describe("Cipher Service", () => {
const result = await firstValueFrom(
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
);
expect(result[cipherId].archivedDate).toBeNull();
expect(result[cipherId].archivedDate).toEqual("2024-01-01T12:00:00.000Z");
expect(result[cipherId].deletedDate).toBeDefined();
});
});

View File

@@ -1568,7 +1568,6 @@ export class CipherService implements CipherServiceAbstraction {
return;
}
ciphers[cipherId].deletedDate = new Date().toISOString();
ciphers[cipherId].archivedDate = null;
};
if (typeof id === "string") {

View File

@@ -165,6 +165,7 @@ describe("DefaultCipherArchiveService", () => {
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
featureFlag.next(true);
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));

View File

@@ -71,8 +71,15 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
return combineLatest([
this.archivedCiphers$(userId),
this.userHasPremium$(userId),
this.hasArchiveFlagEnabled$,
]).pipe(
map(
([archivedCiphers, hasPremium, flagEnabled]) =>
flagEnabled && archivedCiphers.length > 0 && !hasPremium,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}

View File

@@ -217,12 +217,20 @@ export class LockComponent implements OnInit, OnDestroy {
.pipe(
mergeMap(async () => {
if (this.activeAccount?.id != null) {
const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled;
this.unlockOptions = await firstValueFrom(
this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id),
);
if (this.activeUnlockOption == null) {
this.loading = false;
await this.setDefaultActiveUnlockOption(this.unlockOptions);
} else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) {
await this.setDefaultActiveUnlockOption(this.unlockOptions);
if (this.activeUnlockOption === UnlockOption.Biometrics) {
await this.handleBiometricsUnlockEnabled();
}
}
}
}),

View File

@@ -18,7 +18,6 @@ import {
BehaviorSubject,
combineLatest,
firstValueFrom,
from,
map,
merge,
Observable,
@@ -43,8 +42,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ClientType, EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -100,7 +97,6 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
})
export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
private _organizationId$ = new BehaviorSubject<OrganizationId | undefined>(undefined);
private createDefaultLocationFlagEnabled$: Observable<boolean>;
private _showExcludeMyItems = false;
/**
@@ -259,13 +255,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
protected organizationService: OrganizationService,
private accountService: AccountService,
private collectionService: CollectionService,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
@Optional() private router?: Router,
) {}
async ngOnInit() {
this.observeFeatureFlags();
this.observeFormState();
this.observePolicyStatus();
this.observeFormSelections();
@@ -286,12 +280,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.setupPolicyBasedFormState();
}
private observeFeatureFlags(): void {
this.createDefaultLocationFlagEnabled$ = from(
this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation),
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
}
private observeFormState(): void {
this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
this.formDisabled.emit(c === "DISABLED");
@@ -380,32 +368,24 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
/**
* Determine value of showExcludeMyItems. Returns true when:
* CreateDefaultLocation feature flag is on
* AND organizationDataOwnershipPolicy is enabled for the selected organization
* organizationDataOwnershipPolicy is enabled for the selected organization
* AND a valid OrganizationId is present (not exporting from individual vault)
*/
private observeMyItemsExclusionCriteria(): void {
combineLatest({
createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$,
organizationDataOwnershipPolicyEnabledForOrg:
this.organizationDataOwnershipPolicyEnabledForOrg$,
organizationId: this._organizationId$,
})
.pipe(takeUntil(this.destroy$))
.subscribe(
({
createDefaultLocationFlagEnabled,
organizationDataOwnershipPolicyEnabledForOrg,
organizationId,
}) => {
if (!createDefaultLocationFlagEnabled || !organizationId) {
this._showExcludeMyItems = false;
return;
}
.subscribe(({ organizationDataOwnershipPolicyEnabledForOrg, organizationId }) => {
if (!organizationId) {
this._showExcludeMyItems = false;
return;
}
this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg;
},
);
this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg;
});
}
// Setup validator adjustments based on format and encryption type changes

View File

@@ -358,6 +358,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
}
submit = async () => {
let successToast: string = "editedItem";
if (this.cipherForm.invalid) {
this.cipherForm.markAllAsTouched();
@@ -392,6 +393,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
// If the item is archived but user has lost archive permissions, unarchive the item.
if (!userCanArchive && this.updatedCipherView.archivedDate) {
this.updatedCipherView.archivedDate = null;
successToast = "itemRestored";
}
const savedCipher = await this.addEditFormService.saveCipher(
@@ -407,7 +409,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
title: null,
message: this.i18nService.t(
this.config.mode === "edit" || this.config.mode === "partial-edit"
? "editedItem"
? successToast
: "addedItem",
),
});

View File

@@ -1,6 +1,9 @@
<section [formGroup]="itemDetailsForm" class="tw-mb-5 bit-compact:tw-mb-4">
<bit-section-header>
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
@if (showArchiveBadge()) {
<span bitBadge> {{ "archived" | i18n }} </span>
}
<button
*ngIf="!config.hideIndividualVaultFields"
slot="end"

View File

@@ -8,13 +8,16 @@ import { BehaviorSubject, 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 { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SelectComponent } from "@bitwarden/components";
@@ -62,6 +65,8 @@ describe("ItemDetailsSectionComponent", () => {
let i18nService: MockProxy<I18nService>;
let mockConfigService: MockProxy<ConfigService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockCipherArchiveService: MockProxy<CipherArchiveService>;
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
const getInitialCipherView = jest.fn<CipherView | null, []>(() => null);
@@ -90,6 +95,8 @@ describe("ItemDetailsSectionComponent", () => {
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockPolicyService = mock<PolicyService>();
mockPolicyService.policiesByType$.mockReturnValue(of([]));
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockCipherArchiveService = mock<CipherArchiveService>();
await TestBed.configureTestingModule({
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
@@ -99,6 +106,8 @@ describe("ItemDetailsSectionComponent", () => {
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
],
}).compileComponents();
@@ -209,9 +218,10 @@ describe("ItemDetailsSectionComponent", () => {
describe("allowOwnershipChange", () => {
it("should not allow ownership change if in edit mode and the cipher is owned by an organization", () => {
component.config.mode = "edit";
component.originalCipherView = {
fixture.componentRef.setInput("originalCipherView", {
organizationId: "org1",
} as CipherView;
} as CipherView);
expect(component.allowOwnershipChange).toBe(false);
});
@@ -251,7 +261,7 @@ describe("ItemDetailsSectionComponent", () => {
it("should show organization data ownership when the configuration allows", () => {
component.config.mode = "edit";
component.config.organizationDataOwnershipDisabled = true;
component.originalCipherView = {} as CipherView;
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
component.config.organizations = [{ id: "134-433-22" } as Organization];
fixture.detectChanges();
@@ -265,7 +275,7 @@ describe("ItemDetailsSectionComponent", () => {
it("should show organization data ownership when the control is disabled", async () => {
component.config.mode = "edit";
component.config.organizationDataOwnershipDisabled = false;
component.originalCipherView = {} as CipherView;
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
component.config.organizations = [{ id: "134-433-22" } as Organization];
await component.ngOnInit();
fixture.detectChanges();
@@ -360,18 +370,20 @@ describe("ItemDetailsSectionComponent", () => {
});
it("should select the first organization if organization data ownership is enabled", async () => {
component.config.organizationDataOwnershipDisabled = false;
component.config.organizations = [
{ id: "org1", name: "org1" } as Organization,
{ id: "org2", name: "org2" } as Organization,
];
component.originalCipherView = {
const updatedCipher = {
name: "cipher1",
organizationId: null,
folderId: null,
collectionIds: [],
favorite: false,
} as CipherView;
component.config.organizationDataOwnershipDisabled = false;
component.config.organizations = [
{ id: "org1", name: "org1" } as Organization,
{ id: "org2", name: "org2" } as Organization,
];
fixture.componentRef.setInput("originalCipherView", updatedCipher);
await component.ngOnInit();
@@ -469,20 +481,17 @@ describe("ItemDetailsSectionComponent", () => {
it("should show readonly hint if readonly collections are present", async () => {
component.config.mode = "edit";
getInitialCipherView.mockReturnValueOnce({
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as CipherView);
component.originalCipherView = {
const updatedCipher = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as CipherView;
getInitialCipherView.mockReturnValueOnce(updatedCipher);
fixture.componentRef.setInput("originalCipherView", updatedCipher);
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
createMockCollection("col1", "Collection 1", "org1", true, false) as CollectionView,
@@ -539,13 +548,16 @@ describe("ItemDetailsSectionComponent", () => {
i < 4 ? CollectionTypes.SharedCollection : CollectionTypes.DefaultUserCollection,
) as CollectionView,
);
component.originalCipherView = {
const updatedCipher = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col2", "col3"],
favorite: true,
} as CipherView;
fixture.componentRef.setInput("originalCipherView", updatedCipher);
fixture.detectChanges();
await fixture.whenStable();
@@ -567,7 +579,8 @@ describe("ItemDetailsSectionComponent", () => {
createMockCollection("col2", "Collection 2", "org1", false, true) as CollectionView,
createMockCollection("col3", "Collection 3", "org1", true, false) as CollectionView,
];
component.originalCipherView = {
const currentCipher = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
@@ -575,7 +588,9 @@ describe("ItemDetailsSectionComponent", () => {
favorite: true,
} as CipherView;
getInitialCipherView.mockReturnValue(component.originalCipherView);
fixture.componentRef.setInput("originalCipherView", currentCipher);
getInitialCipherView.mockReturnValue(component.originalCipherView());
component.config.organizations = [{ id: "org1" } as Organization];
});
@@ -604,7 +619,8 @@ describe("ItemDetailsSectionComponent", () => {
{ id: "org2", name: "org2" } as Organization,
{ id: "org1", name: "org1" } as Organization,
];
component.originalCipherView = {} as CipherView;
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
await component.ngOnInit();
fixture.detectChanges();
@@ -684,13 +700,16 @@ describe("ItemDetailsSectionComponent", () => {
beforeEach(() => {
component.config.mode = "edit";
component.config.originalCipher = new Cipher();
component.originalCipherView = {
const updatedCipher = {
name: "cipher1",
organizationId: null,
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as unknown as CipherView;
fixture.componentRef.setInput("originalCipherView", updatedCipher);
});
describe("when personal ownership is not allowed", () => {
@@ -701,7 +720,7 @@ describe("ItemDetailsSectionComponent", () => {
describe("cipher does not belong to an organization", () => {
beforeEach(() => {
getInitialCipherView.mockReturnValue(component.originalCipherView!);
getInitialCipherView.mockReturnValue(component.originalCipherView()!);
});
it("enables organizationId", async () => {
@@ -720,8 +739,11 @@ describe("ItemDetailsSectionComponent", () => {
describe("cipher belongs to an organization", () => {
beforeEach(() => {
component.originalCipherView.organizationId = "org-id";
getInitialCipherView.mockReturnValue(component.originalCipherView);
fixture.componentRef.setInput("originalCipherView", {
...component.originalCipherView(),
organizationId: "org-id",
} as CipherView);
getInitialCipherView.mockReturnValue(component.originalCipherView());
});
it("enables the rest of the form", async () => {
@@ -734,8 +756,8 @@ describe("ItemDetailsSectionComponent", () => {
describe("setFormState behavior with null/undefined", () => {
it("calls disableFormFields when organizationId value is null", async () => {
component.originalCipherView.organizationId = null as any;
getInitialCipherView.mockReturnValue(component.originalCipherView);
component.originalCipherView().organizationId = null as any;
getInitialCipherView.mockReturnValue(component.originalCipherView());
await component.ngOnInit();
@@ -743,8 +765,8 @@ describe("ItemDetailsSectionComponent", () => {
});
it("calls disableFormFields when organizationId value is undefined", async () => {
component.originalCipherView.organizationId = undefined;
getInitialCipherView.mockReturnValue(component.originalCipherView);
component.originalCipherView().organizationId = undefined;
getInitialCipherView.mockReturnValue(component.originalCipherView());
await component.ngOnInit();
@@ -752,8 +774,8 @@ describe("ItemDetailsSectionComponent", () => {
});
it("calls enableFormFields when organizationId has a string value", async () => {
component.originalCipherView.organizationId = "org-id" as any;
getInitialCipherView.mockReturnValue(component.originalCipherView);
component.originalCipherView().organizationId = "org-id" as any;
getInitialCipherView.mockReturnValue(component.originalCipherView());
await component.ngOnInit();
@@ -765,11 +787,11 @@ describe("ItemDetailsSectionComponent", () => {
describe("when an ownership change is not allowed", () => {
beforeEach(() => {
component.config.organizationDataOwnershipDisabled = true; // allow personal ownership
component.originalCipherView!.organizationId = undefined;
component.originalCipherView()!.organizationId = undefined;
});
it("disables organizationId when the cipher is owned by an organization", async () => {
component.originalCipherView!.organizationId = "orgId";
component.originalCipherView()!.organizationId = "orgId";
await component.ngOnInit();
@@ -785,4 +807,28 @@ describe("ItemDetailsSectionComponent", () => {
});
});
});
describe("showArchiveBadge", () => {
it("should set showArchiveBadge to true when cipher is archived and client is Desktop", async () => {
component.config.organizations = [{ id: "org1" } as Organization];
const archivedCipher = {
name: "archived cipher",
organizationId: null,
folderId: null,
collectionIds: [],
favorite: false,
isArchived: true,
} as unknown as CipherView;
fixture.componentRef.setInput("originalCipherView", archivedCipher);
getInitialCipherView.mockReturnValueOnce(archivedCipher);
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
expect(component["showArchiveBadge"]()).toBe(true);
});
});
});

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
import { Component, computed, DestroyRef, input, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
@@ -10,18 +10,20 @@ import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeComponent,
CardComponent,
FormFieldModule,
IconButtonModule,
@@ -52,6 +54,7 @@ import { CipherFormContainer } from "../../cipher-form-container";
IconButtonModule,
JslibModule,
CommonModule,
BadgeComponent,
],
})
export class ItemDetailsSectionComponent implements OnInit {
@@ -63,6 +66,14 @@ export class ItemDetailsSectionComponent implements OnInit {
favorite: [false],
});
protected readonly showArchiveBadge = computed(() => {
return (
this.cipherArchiveService.hasArchiveFlagEnabled$ &&
this.originalCipherView()?.isArchived &&
this.platformUtilsService.getClientType() === ClientType.Desktop
);
});
/**
* Collection options available for the selected organization.
* @protected
@@ -91,10 +102,7 @@ export class ItemDetailsSectionComponent implements OnInit {
@Input({ required: true })
config: CipherFormConfig;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
originalCipherView: CipherView;
readonly originalCipherView = input<CipherView>();
get readOnlyCollectionsNames(): string[] {
return this.readOnlyCollections.map((c) => c.name);
@@ -141,8 +149,9 @@ export class ItemDetailsSectionComponent implements OnInit {
private i18nService: I18nService,
private destroyRef: DestroyRef,
private accountService: AccountService,
private configService: ConfigService,
private policyService: PolicyService,
private platformUtilsService: PlatformUtilsService,
private cipherArchiveService: CipherArchiveService,
) {
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
this.itemDetailsForm.valueChanges
@@ -178,7 +187,7 @@ export class ItemDetailsSectionComponent implements OnInit {
get allowOwnershipChange() {
// Do not allow ownership change in edit mode and the cipher is owned by an organization
if (this.config.mode === "edit" && this.originalCipherView?.organizationId != null) {
if (this.config.mode === "edit" && this.originalCipherView()?.organizationId != null) {
return false;
}
@@ -285,14 +294,6 @@ export class ItemDetailsSectionComponent implements OnInit {
return;
}
const isFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.CreateDefaultLocation,
);
if (!isFeatureEnabled) {
return;
}
const selectedOrgHasPolicyEnabled = (
await firstValueFrom(
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, this.userId),
@@ -360,7 +361,7 @@ export class ItemDetailsSectionComponent implements OnInit {
(c) =>
c.organizationId === orgId &&
c.readOnly &&
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
this.originalCipherView().collectionIds.includes(c.id as CollectionId),
);
}
}
@@ -417,8 +418,8 @@ export class ItemDetailsSectionComponent implements OnInit {
* Note: `.every` will return true for an empty array
*/
const cipherIsOnlyInOrgCollections =
(this.originalCipherView?.collectionIds ?? []).length > 0 &&
this.originalCipherView.collectionIds.every(
(this.originalCipherView()?.collectionIds ?? []).length > 0 &&
this.originalCipherView().collectionIds.every(
(cId) =>
this.collections.find((c) => c.id === cId)?.type === CollectionTypes.SharedCollection,
);

View File

@@ -15,15 +15,16 @@
data-testid="toggle-privateKey-visibility"
bitPasswordInputToggle
></button>
<button
type="button"
bitIconButton="bwi-import"
bitSuffix
data-testid="import-privateKey"
*ngIf="showImport"
label="{{ 'importSshKeyFromClipboard' | i18n }}"
(click)="importSshKeyFromClipboard()"
></button>
@if (showImport()) {
<button
type="button"
bitIconButton="bwi-import"
bitSuffix
data-testid="import-privateKey"
label="{{ 'importSshKeyFromClipboard' | i18n }}"
(click)="importSshKeyFromClipboard()"
></button>
}
</bit-form-field>
<bit-form-field>

View File

@@ -0,0 +1,261 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, Subject } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
import { CipherFormContainer } from "../../cipher-form-container";
import { SshKeySectionComponent } from "./sshkey-section.component";
jest.mock("@bitwarden/sdk-internal", () => {
return {
generate_ssh_key: jest.fn(),
};
});
describe("SshKeySectionComponent", () => {
let fixture: ComponentFixture<SshKeySectionComponent>;
let component: SshKeySectionComponent;
const mockI18nService = mock<I18nService>();
let formStatusChange$: Subject<string>;
let cipherFormContainer: {
registerChildForm: jest.Mock;
patchCipher: jest.Mock;
getInitialCipherView: jest.Mock;
formStatusChange$: Subject<string>;
};
let sdkClient$: BehaviorSubject<unknown>;
let sdkService: { client$: BehaviorSubject<unknown> };
let sshImportPromptService: { importSshKeyFromClipboard: jest.Mock };
let platformUtilsService: { getClientType: jest.Mock };
beforeEach(async () => {
formStatusChange$ = new Subject<string>();
cipherFormContainer = {
registerChildForm: jest.fn(),
patchCipher: jest.fn(),
getInitialCipherView: jest.fn(),
formStatusChange$,
};
sdkClient$ = new BehaviorSubject<unknown>({});
sdkService = { client$: sdkClient$ };
sshImportPromptService = {
importSshKeyFromClipboard: jest.fn(),
};
platformUtilsService = {
getClientType: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [SshKeySectionComponent],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: CipherFormContainer, useValue: cipherFormContainer },
{ provide: SdkService, useValue: sdkService },
{ provide: SshImportPromptService, useValue: sshImportPromptService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(SshKeySectionComponent);
component = fixture.componentInstance;
// minimal required inputs
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
fixture.componentRef.setInput("disabled", false);
(generate_ssh_key as unknown as jest.Mock).mockReset();
});
it("registers the sshKeyDetails form with the container in the constructor", () => {
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledTimes(1);
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith(
"sshKeyDetails",
component.sshKeyForm,
);
});
it("patches cipher sshKey whenever the form changes", () => {
component.sshKeyForm.setValue({
privateKey: "priv",
publicKey: "pub",
keyFingerprint: "fp",
});
expect(cipherFormContainer.patchCipher).toHaveBeenCalledTimes(1);
const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0] as (c: any) => any;
const cipher: any = {};
const patched = patchFn(cipher);
expect(patched.sshKey).toBeInstanceOf(SshKeyView);
expect(patched.sshKey.privateKey).toBe("priv");
expect(patched.sshKey.publicKey).toBe("pub");
expect(patched.sshKey.keyFingerprint).toBe("fp");
});
it("ngOnInit uses initial cipher sshKey (prefill) when present and does not generate", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
expect(generate_ssh_key).not.toHaveBeenCalled();
expect(component.sshKeyForm.get("privateKey")?.value).toBe("p1");
expect(component.sshKeyForm.get("publicKey")?.value).toBe("p2");
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("p3");
});
it("ngOnInit falls back to originalCipherView sshKey when prefill is missing", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
fixture.componentRef.setInput("originalCipherView", {
edit: true,
sshKey: { privateKey: "o1", publicKey: "o2", keyFingerprint: "o3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
expect(generate_ssh_key).not.toHaveBeenCalled();
expect(component.sshKeyForm.get("privateKey")?.value).toBe("o1");
expect(component.sshKeyForm.get("publicKey")?.value).toBe("o2");
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("o3");
});
it("ngOnInit generates an ssh key when no sshKey exists and populates the form", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
(generate_ssh_key as unknown as jest.Mock).mockReturnValue({
privateKey: "genPriv",
publicKey: "genPub",
fingerprint: "genFp",
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
expect(generate_ssh_key).toHaveBeenCalledTimes(1);
expect(generate_ssh_key).toHaveBeenCalledWith("Ed25519");
expect(component.sshKeyForm.get("privateKey")?.value).toBe("genPriv");
expect(component.sshKeyForm.get("publicKey")?.value).toBe("genPub");
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("genFp");
});
it("ngOnInit disables the form", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
expect(component.sshKeyForm.disabled).toBe(true);
});
it("sets showImport true when not Web and originalCipherView.edit is true", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
await component.ngOnInit();
expect(component.showImport()).toBe(true);
});
it("keeps showImport false when client type is Web", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Web);
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
await component.ngOnInit();
expect(component.showImport()).toBe(false);
});
it("disables the ssh key form when formStatusChange emits enabled", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
await component.ngOnInit();
component.sshKeyForm.enable();
expect(component.sshKeyForm.disabled).toBe(false);
formStatusChange$.next("enabled");
expect(component.sshKeyForm.disabled).toBe(true);
});
it("renders the import button only when showImport is true", async () => {
cipherFormContainer.getInitialCipherView.mockReturnValue({
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
await component.ngOnInit();
fixture.detectChanges();
const importBtn = fixture.debugElement.query(By.css('[data-testid="import-privateKey"]'));
expect(importBtn).not.toBeNull();
});
it("importSshKeyFromClipboard sets form values when a key is returned", async () => {
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue({
privateKey: "cPriv",
publicKey: "cPub",
keyFingerprint: "cFp",
});
await component.importSshKeyFromClipboard();
expect(component.sshKeyForm.get("privateKey")?.value).toBe("cPriv");
expect(component.sshKeyForm.get("publicKey")?.value).toBe("cPub");
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("cFp");
});
it("importSshKeyFromClipboard does nothing when null is returned", async () => {
component.sshKeyForm.setValue({ privateKey: "a", publicKey: "b", keyFingerprint: "c" });
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue(null);
await component.importSshKeyFromClipboard();
expect(component.sshKeyForm.get("privateKey")?.value).toBe("a");
expect(component.sshKeyForm.get("publicKey")?.value).toBe("b");
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("c");
});
});

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core";
import { Component, computed, DestroyRef, inject, input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -43,15 +43,9 @@ import { CipherFormContainer } from "../../cipher-form-container";
],
})
export class SshKeySectionComponent implements OnInit {
/** The original cipher */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() originalCipherView: CipherView;
readonly originalCipherView = input<CipherView | null>(null);
/** True when all fields should be disabled */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() disabled: boolean;
readonly disabled = input(false);
/**
* All form fields associated with the ssh key
@@ -65,7 +59,14 @@ export class SshKeySectionComponent implements OnInit {
keyFingerprint: [""],
});
showImport = false;
readonly showImport = computed(() => {
return (
// Web does not support clipboard access
this.platformUtilsService.getClientType() !== ClientType.Web &&
this.originalCipherView()?.edit
);
});
private destroyRef = inject(DestroyRef);
constructor(
@@ -90,7 +91,7 @@ export class SshKeySectionComponent implements OnInit {
async ngOnInit() {
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView?.sshKey;
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView()?.sshKey;
if (sshKeyView) {
this.setInitialValues(sshKeyView);
@@ -100,11 +101,6 @@ export class SshKeySectionComponent implements OnInit {
this.sshKeyForm.disable();
// Web does not support clipboard access
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
this.showImport = true;
}
// Disable the form if the cipher form container is enabled
// to prevent user interaction
this.cipherFormContainer.formStatusChange$

View File

@@ -9,7 +9,7 @@
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<bit-item-action class="tw-pr-4">
<app-download-attachment
[admin]="admin"
[cipher]="cipher"

View File

@@ -1,7 +1,7 @@
<section class="tw-mb-5 bit-compact:tw-mb-4">
<bit-card>
<div
class="tw-flex tw-place-items-center"
class="tw-flex tw-place-items-center tw-w-full"
[ngClass]="{
'tw-mb-2': allItems.length > 0,
}"
@@ -10,9 +10,16 @@
<div class="tw-flex tw-items-center tw-justify-center" style="width: 40px; height: 40px">
<app-vault-icon [cipher]="cipher()" [coloredIcon]="true"></app-vault-icon>
</div>
<h2 bitTypography="h4" class="tw-ml-2 tw-mt-2 tw-select-auto" data-testid="item-name">
<h2
bitTypography="h4"
class="tw-ml-2 tw-mt-2 tw-select-auto tw-flex-1"
data-testid="item-name"
>
{{ cipher().name }}
</h2>
@if (showArchiveBadge()) {
<span bitBadge> {{ "archived" | i18n }} </span>
}
</div>
<ng-container>
<div class="tw-flex tw-flex-col tw-mt-2 md:tw-flex-row md:tw-flex-wrap">

View File

@@ -1,6 +1,7 @@
import { ComponentRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -21,6 +22,7 @@ describe("ItemDetailsV2Component", () => {
let component: ItemDetailsV2Component;
let fixture: ComponentFixture<ItemDetailsV2Component>;
let componentRef: ComponentRef<ItemDetailsV2Component>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
const cipher = {
id: "cipher1",
@@ -51,6 +53,8 @@ describe("ItemDetailsV2Component", () => {
} as FolderView;
beforeEach(async () => {
mockPlatformUtilsService = mock<PlatformUtilsService>();
await TestBed.configureTestingModule({
imports: [ItemDetailsV2Component],
providers: [
@@ -61,6 +65,7 @@ describe("ItemDetailsV2Component", () => {
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
],
}).compileComponents();
});
@@ -98,4 +103,31 @@ describe("ItemDetailsV2Component", () => {
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
expect(owner).toBeNull();
});
it("should show archive badge when cipher is archived and client is Desktop", () => {
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
const archivedCipher = { ...cipher, isArchived: true };
componentRef.setInput("cipher", archivedCipher);
expect((component as any).showArchiveBadge()).toBe(true);
});
it("should not show archive badge when cipher is not archived", () => {
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
const unarchivedCipher = { ...cipher, isArchived: false };
componentRef.setInput("cipher", unarchivedCipher);
expect((component as any).showArchiveBadge()).toBe(false);
});
it("should not show archive badge when client is not Desktop", () => {
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Web);
const archivedCipher = { ...cipher, isArchived: true };
componentRef.setInput("cipher", archivedCipher);
expect((component as any).showArchiveBadge()).toBe(false);
});
});

View File

@@ -9,11 +9,14 @@ import { fromEvent, map, startWith } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
BadgeModule,
ButtonLinkDirective,
CardComponent,
FormFieldModule,
@@ -35,6 +38,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
OrgIconDirective,
FormFieldModule,
ButtonLinkDirective,
BadgeModule,
],
})
export class ItemDetailsV2Component {
@@ -85,7 +89,16 @@ export class ItemDetailsV2Component {
}
});
constructor(private i18nService: I18nService) {}
protected readonly showArchiveBadge = computed(() => {
return (
this.cipher().isArchived && this.platformUtilsService.getClientType() === ClientType.Desktop
);
});
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
) {}
toggleShowMore() {
this.showAllDetails.update((value) => !value);

View File

@@ -90,12 +90,15 @@
data-testid="copy-password"
(click)="logCopyEvent()"
></button>
<bit-hint *ngIf="showChangePasswordLink">
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-hint>
@if (showChangePasswordLink) {
<bit-hint class="tw-flex tw-mb-3 tw-items-center">
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
<span class="tw-ml-2 tw-mr-1">{{ "vulnerablePassword" | i18n }}</span>
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
{{ "changeNow" | i18n }}
</a>
</bit-hint>
}
</bit-form-field>
<div
*ngIf="showPasswordCount && passwordRevealed"

10
package-lock.json generated
View File

@@ -81,7 +81,7 @@
"@electron/notarize": "3.0.1",
"@electron/rebuild": "4.0.1",
"@eslint/compat": "2.0.0",
"@lit-labs/signals": "0.1.3",
"@lit-labs/signals": "0.2.0",
"@ngtools/webpack": "20.3.12",
"@nx/devkit": "21.6.10",
"@nx/eslint": "21.6.10",
@@ -8785,14 +8785,14 @@
"license": "BSD-3-Clause"
},
"node_modules/@lit-labs/signals": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.1.3.tgz",
"integrity": "sha512-P0yWgH5blwVyEwBg+WFspLzeu1i0ypJP1QB0l1Omr9qZLIPsUu0p4Fy2jshOg7oQyha5n163K3GJGeUhQQ682Q==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.2.0.tgz",
"integrity": "sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"lit": "^2.0.0 || ^3.0.0",
"signal-polyfill": "^0.2.0"
"signal-polyfill": "^0.2.2"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {

View File

@@ -48,7 +48,7 @@
"@electron/notarize": "3.0.1",
"@electron/rebuild": "4.0.1",
"@eslint/compat": "2.0.0",
"@lit-labs/signals": "0.1.3",
"@lit-labs/signals": "0.2.0",
"@ngtools/webpack": "20.3.12",
"@nx/devkit": "21.6.10",
"@nx/eslint": "21.6.10",

View File

@@ -20,48 +20,48 @@
"paths": {
"@bitwarden/admin-console/common": ["./libs/admin-console/src/common"],
"@bitwarden/angular/*": ["./libs/angular/src/*"],
"@bitwarden/assets": ["libs/assets/src/index.ts"],
"@bitwarden/assets/svg": ["libs/assets/src/svg/index.ts"],
"@bitwarden/assets": ["./libs/assets/src/index.ts"],
"@bitwarden/assets/svg": ["./libs/assets/src/svg/index.ts"],
"@bitwarden/auth/angular": ["./libs/auth/src/angular"],
"@bitwarden/auth/common": ["./libs/auth/src/common"],
"@bitwarden/billing": ["./libs/billing/src"],
"@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"],
"@bitwarden/browser/*": ["./apps/browser/src/*"],
"@bitwarden/cli/*": ["./apps/cli/src/*"],
"@bitwarden/client-type": ["libs/client-type/src/index.ts"],
"@bitwarden/client-type": ["./libs/client-type/src/index.ts"],
"@bitwarden/common/spec": ["./libs/common/spec"],
"@bitwarden/common/*": ["./libs/common/src/*"],
"@bitwarden/components": ["./libs/components/src"],
"@bitwarden/core-test-utils": ["libs/core-test-utils/src/index.ts"],
"@bitwarden/core-test-utils": ["./libs/core-test-utils/src/index.ts"],
"@bitwarden/dirt-card": ["./libs/dirt/card/src"],
"@bitwarden/generator-components": ["./libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["./libs/tools/generator/core/src"],
"@bitwarden/generator-history": ["./libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["./libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["./libs/tools/generator/extensions/navigation/src"],
"@bitwarden/guid": ["libs/guid/src/index.ts"],
"@bitwarden/guid": ["./libs/guid/src/index.ts"],
"@bitwarden/importer-core": ["./libs/importer/src"],
"@bitwarden/importer-ui": ["./libs/importer/src/components"],
"@bitwarden/key-management": ["./libs/key-management/src"],
"@bitwarden/key-management-ui": ["./libs/key-management-ui/src"],
"@bitwarden/logging": ["libs/logging/src"],
"@bitwarden/messaging": ["libs/messaging/src/index.ts"],
"@bitwarden/logging": ["./libs/logging/src"],
"@bitwarden/messaging": ["./libs/messaging/src/index.ts"],
"@bitwarden/node/*": ["./libs/node/src/*"],
"@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"],
"@bitwarden/nx-plugin": ["./libs/nx-plugin/src/index.ts"],
"@bitwarden/platform": ["./libs/platform/src"],
"@bitwarden/platform/*": ["./libs/platform/src/*"],
"@bitwarden/pricing": ["libs/pricing/src/index.ts"],
"@bitwarden/pricing": ["./libs/pricing/src/index.ts"],
"@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"],
"@bitwarden/serialization": ["libs/serialization/src/index.ts"],
"@bitwarden/state": ["libs/state/src/index.ts"],
"@bitwarden/state-internal": ["libs/state-internal/src/index.ts"],
"@bitwarden/state-test-utils": ["libs/state-test-utils/src/index.ts"],
"@bitwarden/storage-core": ["libs/storage-core/src/index.ts"],
"@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"],
"@bitwarden/subscription": ["libs/subscription/src/index.ts"],
"@bitwarden/serialization": ["./libs/serialization/src/index.ts"],
"@bitwarden/state": ["./libs/state/src/index.ts"],
"@bitwarden/state-internal": ["./libs/state-internal/src/index.ts"],
"@bitwarden/state-test-utils": ["./libs/state-test-utils/src/index.ts"],
"@bitwarden/storage-core": ["./libs/storage-core/src/index.ts"],
"@bitwarden/storage-test-utils": ["./libs/storage-test-utils/src/index.ts"],
"@bitwarden/subscription": ["./libs/subscription/src/index.ts"],
"@bitwarden/ui-common": ["./libs/ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
"@bitwarden/user-core": ["libs/user-core/src/index.ts"],
"@bitwarden/user-core": ["./libs/user-core/src/index.ts"],
"@bitwarden/vault": ["./libs/vault/src"],
"@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"],