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:
286
.github/workflows/build-desktop.yml
vendored
286
.github/workflows/build-desktop.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -822,7 +822,6 @@ function createSeededVaultPopupListFiltersService(
|
||||
accountServiceMock,
|
||||
viewCacheServiceMock,
|
||||
restrictedItemTypesServiceMock,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, it’ll 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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
@if (cipherIsArchived) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archiveNoun" | i18n }} </span>
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
<div bitDialogContent #dialogContent>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export type LogoutReason =
|
||||
| "invalidGrantError"
|
||||
| "vaultTimeout"
|
||||
| "invalidSecurityStamp"
|
||||
| "logoutNotification"
|
||||
| "keyConnectorError"
|
||||
| "sessionExpired"
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "accountDeleted"
|
||||
| "invalidAccessToken"
|
||||
| "invalidSecurityStamp"
|
||||
| "keyConnectorError"
|
||||
| "logoutNotification"
|
||||
| "refreshTokenSecureStorageRetrievalFailure"
|
||||
| "accountDeleted";
|
||||
| "sessionExpired"
|
||||
| "vaultTimeout";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1568,7 +1568,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
ciphers[cipherId].deletedDate = new Date().toISOString();
|
||||
ciphers[cipherId].archivedDate = null;
|
||||
};
|
||||
|
||||
if (typeof id === "string") {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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$
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
10
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user