1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 13:40:06 +00:00

Merge branch 'main' into innovation/archive/clients

This commit is contained in:
Shane
2025-03-20 16:34:06 -07:00
36 changed files with 341 additions and 158 deletions

View File

@@ -312,6 +312,7 @@ jobs:
cosign sign --yes ${images}
- name: Scan Docker image
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
id: container-scan
uses: anchore/scan-action@869c549e657a088dc0441b08ce4fc0ecdac2bb65 # v5.3.0
with:
@@ -320,9 +321,12 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
- name: Log out of Docker
run: docker logout

View File

@@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.3.0",
"version": "2025.3.1",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -1,6 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -59,6 +60,7 @@ describe("NotificationBackground", () => {
const themeStateService = mock<ThemeStateService>();
const configService = mock<ConfigService>();
const accountService = mock<AccountService>();
const organizationService = mock<OrganizationService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
@@ -73,18 +75,19 @@ describe("NotificationBackground", () => {
authService.activeAccountStatus$ = activeAccountStatusMock$;
accountService.activeAccount$ = activeAccountSubject;
notificationBackground = new NotificationBackground(
accountService,
authService,
autofillService,
cipherService,
authService,
policyService,
folderService,
userNotificationSettingsService,
configService,
domainSettingsService,
environmentService,
folderService,
logService,
organizationService,
policyService,
themeStateService,
configService,
accountService,
userNotificationSettingsService,
);
});

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -63,46 +64,48 @@ export default class NotificationBackground {
ExtensionCommand.AutofillIdentity,
]);
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
bgGetFolderData: () => this.getFolderData(),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
bgAdjustNotificationBar: ({ message, sender }) =>
this.handleAdjustNotificationBarMessage(message, sender),
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender),
bgRemoveTabFromNotificationQueue: ({ sender }) =>
this.removeTabFromNotificationQueue(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
collectPageDetailsResponse: ({ message }) =>
this.handleCollectPageDetailsResponseMessage(message),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
bgGetExcludedDomains: () => this.getExcludedDomains(),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetFolderData: () => this.getFolderData(),
bgGetOrgData: () => this.getOrgData(),
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
bgRemoveTabFromNotificationQueue: ({ sender }) =>
this.removeTabFromNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
collectPageDetailsResponse: ({ message }) =>
this.handleCollectPageDetailsResponseMessage(message),
getWebVaultUrlForNotification: () => this.getWebVaultUrl(),
notificationRefreshFlagValue: () => this.getNotificationFlag(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
};
constructor(
private accountService: AccountService,
private authService: AuthService,
private autofillService: AutofillService,
private cipherService: CipherService,
private authService: AuthService,
private policyService: PolicyService,
private folderService: FolderService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private configService: ConfigService,
private domainSettingsService: DomainSettingsService,
private environmentService: EnvironmentService,
private folderService: FolderService,
private logService: LogService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private themeStateService: ThemeStateService,
private configService: ConfigService,
private accountService: AccountService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
) {}
init() {
@@ -744,6 +747,26 @@ export default class NotificationBackground {
);
}
/**
* Returns the first value found from the organization service organizations$ observable.
*/
private async getOrgData() {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const organizations = await firstValueFrom(
this.organizationService.organizations$(activeUserId),
);
return organizations.map((org) => {
const { id, name, productTierType } = org;
return {
id,
name,
productTierType,
};
});
}
/**
* Handles the unlockCompleted extension message. Will close the notification bar
* after an attempted autofill action, and retry the autofill action if the message

View File

@@ -16,12 +16,12 @@ const { css } = createEmotion({
});
export function NotificationBody({
ciphers,
ciphers = [],
notificationType,
theme = ThemeTypes.Light,
handleEditOrUpdateAction,
}: {
ciphers: NotificationCipherData[];
ciphers?: NotificationCipherData[];
customClasses?: string[];
notificationType?: NotificationType;
theme: Theme;

View File

@@ -60,23 +60,18 @@ export function NotificationButtonRow({
)
: ([] as Option[]);
const noFolderOption: Option = {
default: true,
icon: Folder,
text: "No folder", // @TODO localize
value: "0",
};
const folderOptions: Option[] = folders?.length
? folders.reduce(
? folders.reduce<Option[]>(
(options, { id, name }: FolderView) => [
...options,
{
icon: Folder,
text: name,
value: id,
value: id === null ? "0" : id,
default: id === null,
},
],
[noFolderOption],
[],
)
: [];

View File

@@ -9,6 +9,7 @@ import {
NotificationType,
} from "../../../notification/abstractions/notification-bar";
import { NotificationCipherData } from "../cipher/types";
import { FolderView, OrgView } from "../common-types";
import { themes, spacing } from "../constants/styles";
import { NotificationBody, componentClassPrefix as notificationBodyClassPrefix } from "./body";
@@ -20,20 +21,24 @@ import {
export function NotificationContainer({
handleCloseNotification,
handleEditOrUpdateAction,
handleSaveAction,
ciphers,
folders,
i18n,
organizations,
theme = ThemeTypes.Light,
type,
ciphers,
handleSaveAction,
handleEditOrUpdateAction,
}: NotificationBarIframeInitData & {
handleCloseNotification: (e: Event) => void;
handleSaveAction: (e: Event) => void;
handleEditOrUpdateAction: (e: Event) => void;
} & {
ciphers?: NotificationCipherData[];
folders?: FolderView[];
i18n: { [key: string]: string };
organizations?: OrgView[];
type: NotificationType; // @TODO typing override for generic `NotificationBarIframeInitData.type`
ciphers: NotificationCipherData[];
}) {
const headerMessage = getHeaderMessage(i18n, type);
const showBody = true;
@@ -42,8 +47,8 @@ export function NotificationContainer({
<div class=${notificationContainerStyles(theme)}>
${NotificationHeader({
handleCloseNotification,
standalone: showBody,
message: headerMessage,
standalone: showBody,
theme,
})}
${showBody
@@ -56,9 +61,11 @@ export function NotificationContainer({
: null}
${NotificationFooter({
handleSaveAction,
theme,
notificationType: type,
folders,
i18n,
notificationType: type,
organizations,
theme,
})}
</div>
`;

View File

@@ -880,7 +880,7 @@ async function loadNotificationBar() {
const baseStyle = useComponentBar
? isNotificationFresh
? "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(100%); opacity:0;"
? "height: calc(276px + 50px); width: 450px; right: 0; transform:translateX(100%); opacity:0;"
: "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(0%); opacity:1;"
: "height: 42px; width: 100%;";
@@ -910,7 +910,7 @@ async function loadNotificationBar() {
function getFrameStyle(useComponentBar: boolean): string {
return (
(useComponentBar
? "height: calc(276px + 25px); width: 450px; right: 0;"
? "height: calc(276px + 50px); width: 450px; right: 0;"
: "height: 42px; width: 100%; left: 0;") +
" top: 0; padding: 0; position: fixed;" +
" z-index: 2147483647; visibility: visible;"

View File

@@ -1,5 +1,8 @@
import { Theme } from "@bitwarden/common/platform/enums";
import { NotificationCipherData } from "../../../autofill/content/components/cipher/types";
import { FolderView, OrgView } from "../../../autofill/content/components/common-types";
const NotificationTypes = {
Add: "add",
Change: "change",
@@ -9,21 +12,24 @@ const NotificationTypes = {
type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes];
type NotificationBarIframeInitData = {
type?: string; // @TODO use `NotificationType`
isVaultLocked?: boolean;
theme?: Theme;
removeIndividualVault?: boolean;
importType?: string;
applyRedesign?: boolean;
ciphers?: NotificationCipherData[];
folders?: FolderView[];
importType?: string;
isVaultLocked?: boolean;
launchTimestamp?: number;
organizations?: OrgView[];
removeIndividualVault?: boolean;
theme?: Theme;
type?: string; // @TODO use `NotificationType`
};
type NotificationBarWindowMessage = {
cipherId?: string;
command: string;
error?: string;
initData?: NotificationBarIframeInitData;
username?: string;
cipherId?: string;
};
type NotificationBarWindowMessageHandlers = {

View File

@@ -5,6 +5,8 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l
import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
import { NotificationCipherData } from "../content/components/cipher/types";
import { OrgView } from "../content/components/common-types";
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation-container";
import { NotificationContainer } from "../content/components/notification/container";
import { buildSvgDomElement } from "../utils";
@@ -115,7 +117,7 @@ function setElementText(template: HTMLTemplateElement, elementId: string, text:
}
}
function initNotificationBar(message: NotificationBarWindowMessage) {
async function initNotificationBar(message: NotificationBarWindowMessage) {
const { initData } = message;
if (!initData) {
return;
@@ -131,7 +133,23 @@ function initNotificationBar(message: NotificationBarWindowMessage) {
// Current implementations utilize a require for scss files which creates the need to remove the node.
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, (cipherData) => {
await Promise.all([
new Promise<OrgView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
),
new Promise<FolderView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetFolderData" }, resolve),
),
new Promise<NotificationCipherData[]>((resolve) =>
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
),
]).then(([organizations, folders, ciphers]) => {
notificationBarIframeInitData = {
...notificationBarIframeInitData,
folders,
ciphers,
organizations,
};
// @TODO use context to avoid prop drilling
return render(
NotificationContainer({
@@ -142,7 +160,6 @@ function initNotificationBar(message: NotificationBarWindowMessage) {
handleSaveAction,
handleEditOrUpdateAction,
i18n,
ciphers: cipherData,
}),
document.body,
);

View File

@@ -1173,18 +1173,19 @@ export default class MainBackground {
() => this.generatePasswordToClipboard(),
);
this.notificationBackground = new NotificationBackground(
this.accountService,
this.authService,
this.autofillService,
this.cipherService,
this.authService,
this.policyService,
this.folderService,
this.userNotificationSettingsService,
this.configService,
this.domainSettingsService,
this.environmentService,
this.folderService,
this.logService,
this.organizationService,
this.policyService,
this.themeStateService,
this.configService,
this.accountService,
this.userNotificationSettingsService,
);
this.overlayNotificationsBackground = new OverlayNotificationsBackground(

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.0",
"version": "2025.3.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.0",
"version": "2025.3.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,14 +3,19 @@ import { BrowserApi } from "../browser/browser-api";
import BrowserClipboardService from "../services/browser-clipboard.service";
describe("OffscreenDocument", () => {
const browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener");
const browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy");
const browserClipboardServiceReadSpy = jest.spyOn(BrowserClipboardService, "read");
const consoleErrorSpy = jest.spyOn(console, "error");
let browserClipboardServiceCopySpy: jest.SpyInstance;
let browserClipboardServiceReadSpy: jest.SpyInstance;
let browserApiMessageListenerSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("../offscreen-document/offscreen-document");
beforeEach(async () => {
browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener");
browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy");
browserClipboardServiceReadSpy = jest.spyOn(BrowserClipboardService, "read");
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
await import("./offscreen-document");
});
describe("init", () => {
it("sets up a `chrome.runtime.onMessage` listener", () => {
@@ -47,6 +52,7 @@ describe("OffscreenDocument", () => {
it("copies the message text", async () => {
const text = "test";
browserClipboardServiceCopySpy.mockResolvedValueOnce(undefined);
sendMockExtensionMessage({ command: "offscreenCopyToClipboard", text });
await flushPromises();
@@ -56,6 +62,7 @@ describe("OffscreenDocument", () => {
describe("handleOffscreenReadFromClipboard", () => {
it("reads the value from the clipboard service", async () => {
browserClipboardServiceReadSpy.mockResolvedValueOnce("");
sendMockExtensionMessage({ command: "offscreenReadFromClipboard" });
await flushPromises();

View File

@@ -2,9 +2,10 @@ import BrowserClipboardService from "./browser-clipboard.service";
describe("BrowserClipboardService", () => {
let windowMock: any;
const consoleWarnSpy = jest.spyOn(console, "warn");
let consoleWarnSpy: any;
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
windowMock = {
navigator: {
clipboard: {
@@ -104,8 +105,6 @@ describe("BrowserClipboardService", () => {
});
await BrowserClipboardService.read(windowMock as Window);
expect(consoleWarnSpy).toHaveBeenCalled();
});
});
});

View File

@@ -185,7 +185,9 @@ describe("Browser Utils Service", () => {
describe("copyToClipboard", () => {
const getManifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
const sendMessageToAppSpy = jest.spyOn(SafariApp, "sendMessageToApp");
const clipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy");
const clipboardServiceCopySpy = jest
.spyOn(BrowserClipboardService, "copy")
.mockResolvedValue(undefined);
let triggerOffscreenCopyToClipboardSpy: jest.SpyInstance;
beforeEach(() => {
@@ -281,7 +283,9 @@ describe("Browser Utils Service", () => {
describe("readFromClipboard", () => {
const getManifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
const sendMessageToAppSpy = jest.spyOn(SafariApp, "sendMessageToApp");
const clipboardServiceReadSpy = jest.spyOn(BrowserClipboardService, "read");
const clipboardServiceReadSpy = jest
.spyOn(BrowserClipboardService, "read")
.mockResolvedValue("");
beforeEach(() => {
getManifestVersionSpy.mockReturnValue(2);

View File

@@ -42,12 +42,12 @@
</ng-container>
</div>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button bitButton bitDialogClose buttonType="primary" type="button">
{{ "close" | i18n }}
</button>
<button bitButton type="button" (click)="copyVersion()">
{{ "copy" | i18n }}
</button>
</div>
</ng-container>
</bit-simple-dialog>

View File

@@ -440,6 +440,32 @@ const mapAddEditCipherInfoToInitialValues = (
initialValues.name = cipher.name;
}
if (cipher.type === CipherType.Card) {
const card = cipher.card;
if (card != null) {
if (card.cardholderName != null) {
initialValues.cardholderName = card.cardholderName;
}
if (card.number != null) {
initialValues.number = card.number;
}
if (card.expMonth != null) {
initialValues.expMonth = card.expMonth;
}
if (card.expYear != null) {
initialValues.expYear = card.expYear;
}
if (card.code != null) {
initialValues.code = card.code;
}
}
}
if (cipher.type === CipherType.Login) {
const login = cipher.login;

View File

@@ -14,13 +14,13 @@
<b>{{params.cipherName}}</b>
{{ "sshkeyApprovalMessageSuffix" | i18n }} {{ params.action | i18n }}
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
<span>{{ "authorize" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "deny" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -187,7 +187,7 @@
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
@@ -196,22 +196,25 @@
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>{{ "invited" | i18n }}</span
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>{{ "needsConfirmation" | i18n }}</span
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>{{ "revoked" | i18n }}</span
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}

View File

@@ -52,7 +52,7 @@
[color]="c.avatarColor"
size="small"
></bit-avatar>
<span>
<span class="tw-inline-flex tw-gap-2">
<a bitLink href="#" appStopClick (click)="edit(c)">{{ c.email }}</a>
<span
bitBadge
@@ -175,7 +175,7 @@
[color]="c.avatarColor"
size="small"
></bit-avatar>
<span>
<span class="tw-inline-flex tw-gap-2">
<span>{{ c.email }}</span>
<span bitBadge *ngIf="c.status === emergencyAccessStatusType.Invited">{{
"invited" | i18n

View File

@@ -13,6 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -65,6 +66,7 @@ describe("EmergencyViewDialogComponent", () => {
useValue: ChangeLoginPasswordService,
},
{ provide: ConfigService, useValue: ConfigService },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
add: {
@@ -79,6 +81,7 @@ describe("EmergencyViewDialogComponent", () => {
useValue: mock<ChangeLoginPasswordService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
})

View File

@@ -180,7 +180,7 @@ export class DeviceManagementComponent {
private updateDeviceTable(devices: Array<DeviceView>): void {
this.dataSource.data = devices
.map((device: DeviceView): DeviceTableData | null => {
if (device.id == undefined) {
if (!device.id) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
@@ -190,7 +190,7 @@ export class DeviceManagementComponent {
return null;
}
if (device.creationDate == undefined) {
if (!device.creationDate) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);

View File

@@ -1,24 +1,26 @@
<h2 bitTypography="h2">
{{ "logInWithPasskey" | i18n }}
<ng-container *ngIf="hasData">
<span
*ngIf="requireSsoPolicyEnabled"
bitBadge
variant="secondary"
class="tw-max-w-full !tw-align-middle"
>
{{ "off" | i18n }} - {{ "ssoLoginIsRequired" | i18n }}
</span>
<ng-container *ngIf="!requireSsoPolicyEnabled">
<span *ngIf="hasCredentials" bitBadge variant="success" class="!tw-align-middle">{{
"on" | i18n
}}</span>
<span *ngIf="!hasCredentials" bitBadge variant="secondary" class="!tw-align-middle">{{
"off" | i18n
}}</span>
<span class="tw-inline-flex tw-gap-1 tw-align-middle">
<ng-container *ngIf="hasData">
<span
*ngIf="requireSsoPolicyEnabled"
bitBadge
variant="secondary"
class="tw-max-w-full !tw-align-middle"
>
{{ "off" | i18n }} - {{ "ssoLoginIsRequired" | i18n }}
</span>
<ng-container *ngIf="!requireSsoPolicyEnabled">
<span *ngIf="hasCredentials" bitBadge variant="success" class="!tw-align-middle">
{{ "on" | i18n }}
</span>
<span *ngIf="!hasCredentials" bitBadge variant="secondary" class="!tw-align-middle">
{{ "off" | i18n }}
</span>
</ng-container>
</ng-container>
</ng-container>
<span bitBadge variant="warning" class="!tw-align-middle">{{ "beta" | i18n }}</span>
<span bitBadge variant="warning" class="!tw-align-middle">{{ "beta" | i18n }}</span>
</span>
<ng-container *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
</ng-container>

View File

@@ -49,8 +49,9 @@
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
{{ row.name }}
</a>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>

View File

@@ -7,25 +7,27 @@
<span bitDialogTitle>
<span *ngIf="!data.orgDomain">{{ "newDomain" | i18n }}</span>
<span *ngIf="data.orgDomain">
{{ ((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") | i18n }}
</span>
<span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">
{{ data.orgDomain.domainName }}
</span>
<span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge variant="warning">
{{
((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") | i18n
}}</span
>
<span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">{{
data.orgDomain.domainName
}}</span>
<span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge variant="warning">{{
((accountDeprovisioningEnabled$ | async)
? "domainStatusUnderVerification"
: "domainStatusUnverified"
) | i18n
}}</span>
<span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge variant="success">{{
((accountDeprovisioningEnabled$ | async) ? "domainStatusClaimed" : "domainStatusVerified")
| i18n
}}</span>
((accountDeprovisioningEnabled$ | async)
? "domainStatusUnderVerification"
: "domainStatusUnverified"
) | i18n
}}
</span>
<span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge variant="success">
{{
((accountDeprovisioningEnabled$ | async) ? "domainStatusClaimed" : "domainStatusVerified")
| i18n
}}
</span>
</span>
<div bitDialogContent>
<bit-form-field>

View File

@@ -225,9 +225,10 @@ describe("NotificationsService", () => {
});
it.each([
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
// Temporarily rolling back notifications being connected while locked
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
// { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
])(
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
@@ -252,7 +253,11 @@ describe("NotificationsService", () => {
},
);
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
it.each([
// Temporarily disabling notifications connecting while in a locked state
// AuthenticationStatus.Locked,
AuthenticationStatus.Unlocked,
])(
"connects when a user transitions from logged out to %s",
async (newStatus: AuthenticationStatus) => {
emitActiveUser(mockUser1);

View File

@@ -123,13 +123,13 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
);
}
// This method name is a lie currently as we also have an access token
// when locked, this is eventually where we want to be but it increases load
// on signalR so we are rolling back until we can move the load of browser to
// web push.
private hasAccessToken$(userId: UserId) {
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
);
}

View File

@@ -8,6 +8,24 @@ describe("MSecureCsvImporter.parse", () => {
importer = new MSecureCsvImporter();
});
it("should correctly parse legacy formatted cards", async () => {
const mockCsvData =
`aWeirdOldStyleCard|1032,Credit Card,,Security code 1234,Card Number|12|5555 4444 3333 2222,Expiration Date|11|04/0029,Name on Card|9|Obi Wan Kenobi,Security Code|9|444,`.trim();
const result = await importer.parse(mockCsvData);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
const cipher = result.ciphers[0];
expect(cipher.name).toBe("aWeirdOldStyleCard");
expect(cipher.type).toBe(CipherType.Card);
expect(cipher.card.number).toBe("5555 4444 3333 2222");
expect(cipher.card.expiration).toBe("04 / 2029");
expect(cipher.card.code).toBe("444");
expect(cipher.card.cardholderName).toBe("Obi Wan Kenobi");
expect(cipher.notes).toBe("Security code 1234");
expect(cipher.card.brand).toBe("");
});
it("should correctly parse credit card entries as Secret Notes", async () => {
const mockCsvData =
`myCreditCard|155089404,Credit Card,,,Card Number|12|41111111111111111,Expiration Date|11|05/2026,Security Code|9|123,Name on Card|0|John Doe,PIN|9|1234,Issuing Bank|0|Visa,Phone Number|4|,Billing Address|0|,`.trim();

View File

@@ -43,23 +43,34 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
).split("/");
cipher.card.expMonth = month.trim();
cipher.card.expYear = year.trim();
cipher.card.code = this.getValueOrDefault(this.splitValueRetainingLastPart(value[6]));
cipher.card.cardholderName = this.getValueOrDefault(
this.splitValueRetainingLastPart(value[7]),
const securityCodeRegex = RegExp("^Security Code\\|\\d*\\|");
const securityCodeEntry = value.find((entry: string) => securityCodeRegex.test(entry));
cipher.card.code = this.getValueOrDefault(
this.splitValueRetainingLastPart(securityCodeEntry),
);
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]));
cipher.notes =
this.getValueOrDefault(value[8].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[8]), "") +
"\n" +
this.getValueOrDefault(value[10].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[10]), "") +
"\n" +
this.getValueOrDefault(value[11].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[11]), "");
const cardNameRegex = RegExp("^Name on Card\\|\\d*\\|");
const nameOnCardEntry = value.find((entry: string) => entry.match(cardNameRegex));
cipher.card.cardholderName = this.getValueOrDefault(
this.splitValueRetainingLastPart(nameOnCardEntry),
);
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]), "");
const noteRegex = RegExp("\\|\\d*\\|");
const rawNotes = value
.slice(2)
.filter((entry: string) => !this.isNullOrWhitespace(entry) && !noteRegex.test(entry));
const noteIndexes = [8, 10, 11];
const indexedNotes = noteIndexes
.filter((idx) => value[idx] && noteRegex.test(value[idx]))
.map((idx) => value[idx])
.map((val) => {
const key = val.split("|")[0];
const value = this.getValueOrDefault(this.splitValueRetainingLastPart(val), "");
return `${key}: ${value}`;
});
cipher.notes = [...rawNotes, ...indexedNotes].join("\n");
} else if (value.length > 3) {
cipher.type = CipherType.SecureNote;
cipher.secureNote = new SecureNoteView();
@@ -95,6 +106,6 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
// like "Password|8|myPassword", we want to keep the "myPassword" but also ensure that if
// the value contains any "|" it works fine
private splitValueRetainingLastPart(value: string) {
return value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
return value && value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
}
}

View File

@@ -25,6 +25,11 @@ export type OptionalInitialValues = {
username?: string;
password?: string;
name?: string;
cardholderName?: string;
number?: string;
expMonth?: string;
expYear?: string;
code?: string;
};
/**

View File

@@ -65,6 +65,8 @@ describe("CardDetailsSectionComponent", () => {
cardView.cardholderName = "Ron Burgundy";
cardView.number = "4242 4242 4242 4242";
cardView.brand = "Visa";
cardView.expMonth = "";
cardView.code = "";
expect(patchCipherSpy).toHaveBeenCalled();
const patchFn = patchCipherSpy.mock.lastCall[0];
@@ -79,6 +81,10 @@ describe("CardDetailsSectionComponent", () => {
});
const cardView = new CardView();
cardView.cardholderName = "";
cardView.number = "";
cardView.expMonth = "";
cardView.code = "";
cardView.expYear = "2022";
expect(patchCipherSpy).toHaveBeenCalled();

View File

@@ -97,6 +97,10 @@ export class CardDetailsSectionComponent implements OnInit {
EventType = EventType;
get initialValues() {
return this.cipherFormContainer.config.initialValues;
}
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
@@ -139,7 +143,9 @@ export class CardDetailsSectionComponent implements OnInit {
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
if (prefillCipher) {
this.setInitialValues(prefillCipher);
this.initFromExistingCipher(prefillCipher.card);
} else {
this.initNewCipher();
}
if (this.disabled) {
@@ -147,6 +153,26 @@ export class CardDetailsSectionComponent implements OnInit {
}
}
private initFromExistingCipher(existingCard: CardView) {
this.cardDetailsForm.patchValue({
cardholderName: this.initialValues?.cardholderName ?? existingCard.cardholderName,
number: this.initialValues?.number ?? existingCard.number,
expMonth: this.initialValues?.expMonth ?? existingCard.expMonth,
expYear: this.initialValues?.expYear ?? existingCard.expYear,
code: this.initialValues?.code ?? existingCard.code,
});
}
private initNewCipher() {
this.cardDetailsForm.patchValue({
cardholderName: this.initialValues?.cardholderName || "",
number: this.initialValues?.number || "",
expMonth: this.initialValues?.expMonth || "",
expYear: this.initialValues?.expYear || "",
code: this.initialValues?.code || "",
});
}
/** Get the section heading based on the card brand */
getSectionHeading(): string {
const { brand } = this.cardDetailsForm.value;

View File

@@ -15,7 +15,8 @@ import { isCardExpired } from "@bitwarden/common/autofill/utils";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -87,6 +88,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
private platformUtilsService: PlatformUtilsService,
private changeLoginPasswordService: ChangeLoginPasswordService,
private configService: ConfigService,
private cipherService: CipherService,
) {}
async ngOnChanges() {
@@ -152,7 +154,12 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
const userId = await firstValueFrom(this.activeUserId$);
if (this.cipher.edit && this.cipher.viewPassword) {
// Show Tasks for Manage and Edit permissions
// Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions
const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId];
if (cipherServiceCipher?.edit && cipherServiceCipher?.viewPassword) {
await this.checkPendingChangePasswordTasks(userId);
}

2
package-lock.json generated
View File

@@ -189,7 +189,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2025.3.0"
"version": "2025.3.1"
},
"apps/cli": {
"name": "@bitwarden/cli",